├── .babelrc
├── .deploy_key.enc
├── .deploy_key.pub
├── .editorconfig
├── .env.sample
├── .eslintrc.yaml
├── .gitignore
├── .gitlab-ci.yml
├── .npmignore
├── .npmrc
├── .travis.yml
├── LICENSE
├── README.md
├── appveyor.yml
├── browserslists
├── doc
├── README.md
├── api.md
├── img
│ └── kos-attendance-list.png
├── porovnani.rst
└── pozadavky.md
├── ictweb.yml
├── package.json
├── script
├── build-static
├── pre-deploy
└── travis-deploy
├── src
├── Root.jsx
├── actions
│ ├── clientActions.js
│ ├── dataActions.js
│ ├── filterActions.js
│ ├── linkActions.js
│ ├── searchActions.js
│ ├── semesterActions.js
│ ├── settingsActions.js
│ ├── uiActions.js
│ └── userActions.js
├── app.js
├── callbacks
│ ├── faux.js
│ ├── index.js
│ └── sirius.js
├── client.js
├── components
│ ├── Controls.jsx
│ ├── Day.jsx
│ ├── ErrorMessage.jsx
│ ├── Event.jsx
│ ├── EventDetail.jsx
│ ├── Footer.jsx
│ ├── FunctionFilter.jsx
│ ├── FunctionSettings.jsx
│ ├── FunctionsBar.jsx
│ ├── FunctionsSidebar.jsx
│ ├── Grid.jsx
│ ├── Header.jsx
│ ├── HourLabel.jsx
│ ├── LogoutButton.jsx
│ ├── NowIndicator.jsx
│ ├── PeriodicUpdate.jsx
│ ├── PositionedExpander.jsx
│ ├── Search.jsx
│ ├── SemesterWeek.jsx
│ ├── SidebarIcal.jsx
│ ├── Spinner.jsx
│ ├── Timetable.jsx
│ ├── Toggleable.jsx
│ ├── UserMenu.jsx
│ ├── WeekNav.jsx
│ └── WeekSwitcher.jsx
├── config.js
├── constants
│ ├── actionTypes.js
│ ├── api.js
│ ├── events.js
│ ├── index.js
│ ├── propTypes.js
│ └── screenSizes.js
├── containers
│ └── FittableContainer.jsx
├── dataManipulation.js
├── date.js
├── error-unauthorized.html
├── error.html
├── globalApi.js
├── images
│ ├── landing-page-devices.png
│ └── logo.png
├── index.html
├── landing.html
├── locale.js
├── locales
│ ├── cs.json
│ └── en.json
├── reducers
│ ├── clientReducer.js
│ ├── dataReducer.js
│ ├── index.js
│ ├── searchReducer.js
│ ├── semesterReducer.js
│ ├── settingsReducer.js
│ ├── uiReducer.js
│ └── userReducer.js
├── routes.js
├── screen.js
├── selectors
│ └── routerSelector.js
├── semester.js
├── semesterWeeks.js
├── signpost.html
├── store
│ ├── index.js
│ └── persistence.js
├── stylesheets
│ ├── _base.scss
│ ├── _components.scss
│ ├── _mixins.scss
│ ├── _reset.scss
│ ├── _settings.scss
│ ├── components
│ │ ├── _LogoutButton.scss
│ │ ├── _SemesterWeek.scss
│ │ ├── _SidebarIcal.scss
│ │ ├── _error.scss
│ │ ├── _flags-control.scss
│ │ ├── _footer.scss
│ │ ├── _function-filter.scss
│ │ ├── _function-settings.scss
│ │ ├── _functions-bar-responsive.scss
│ │ ├── _functions-bar.scss
│ │ ├── _functions-sidebar-responsive.scss
│ │ ├── _functions-sidebar.scss
│ │ ├── _grid.scss
│ │ ├── _header-responsive.scss
│ │ ├── _header.scss
│ │ ├── _landing-page.scss
│ │ ├── _search.scss
│ │ ├── _signpost-page.scss
│ │ ├── _spinner.scss
│ │ ├── _table-animations.scss
│ │ ├── _table-appearances.scss
│ │ ├── _table-events-colors.scss
│ │ ├── _table-events-detail.scss
│ │ ├── _table-events.scss
│ │ ├── _table-horizontal.scss
│ │ ├── _table-responsive.scss
│ │ ├── _table.scss
│ │ ├── _ukraine-ribbon.scss
│ │ ├── _week-nav-responsive.scss
│ │ ├── _week-nav.scss
│ │ └── _week-switcher.scss
│ └── fittable.scss
├── time.js
├── timetable.js
├── utils.js
└── utils
│ ├── safeExpandingDirection.js
│ └── suitClassName.js
├── test
├── .eslintrc.yaml
├── actions
│ ├── clientActions.test.js
│ ├── dataActions.test.js
│ ├── filterActions.test.js
│ ├── linkActions.test.js
│ ├── searchActions.test.js
│ ├── semesterActions.test.js
│ ├── settingsActions.test.js
│ ├── uiActions.test.js
│ └── userActions.test.js
├── client.test.js
├── dataManipulation.test.js
├── date.test.js
├── helpers
│ └── routerState.js
├── reducers
│ ├── clientReducer.test.js
│ ├── dataReducer.test.js
│ ├── index.test.js
│ ├── searchReducer.test.js
│ ├── semesterReducer.test.js
│ ├── uiReducer.test.js
│ └── userReducer.test.js
├── screen.test.js
├── selectors
│ └── routerSelector.test.js
├── semester.test.js
├── semesterWeeks.test.js
├── time.test.js
├── timetable.test.js
├── utils.test.js
└── utils
│ ├── safeExpandingDirection.test.js
│ └── suitClassName.test.js
├── webpack.config.js
├── webpack.production.config.js
├── webpack
└── config.js
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "stage": 0,
3 | "optional": ["runtime"]
4 | }
5 |
--------------------------------------------------------------------------------
/.deploy_key.enc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cvut/fittable/209389f08b01749cb81bf0d3215b7480354b9328/.deploy_key.enc
--------------------------------------------------------------------------------
/.deploy_key.pub:
--------------------------------------------------------------------------------
1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDpz+wSLdX20arMJtKsgUG45PxKdoLojPYNz89i+Ts0XORHDuUjUSMdUCNtleUyoMpnVuoImUIv6njvkgYtbiuaSC8EnefIuEfbsysg4znJ6rT3/ZzsXT4yuEEuBhA4tc4nC6R/r/hkxjx2V+E3VZ06q36Sbx+JZxrxEYx36SmHuRk61cJOkyR0hNk3o2psiucTflSFGWG1cYlavxOdRYrpoJ7b6Db9H2I8pEG8dvtL2+klHiiScP9vHOQWCZkCJGwLt0VuP06ohzICbBFqbAqC2p+YBczZUUSxj9xgKKaniuCGUveV2vox9X29EzN2hYHb4a95XHYp2XIv2LgVZY4UcfDwFX1pudtfRdlL0zo8hSZZaeLfalEGCPQtDaSBqX/newj2k09DeETUHVa2Vy2/p4NBNBIASbIESWGYMy/5DTNRJI2AHIBd7MmEJJZtp6YvMTT34i8rwa7OMcl40iJgQCQGX/X5QqTFJmGaSiLEKhPaRZmF4GuI0mJvmNhN0hU= fittable@travis
2 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # top-most editorconfig file
2 | root = true
3 |
4 | [*]
5 | charset = utf-8
6 | end_of_line = lf
7 | insert_final_newline = true
8 | trim_trailing_whitespace = true
9 | indent_style = space
10 | indent_size = 2
11 |
12 | [/script/*]
13 | indent_size = 4
14 |
--------------------------------------------------------------------------------
/.env.sample:
--------------------------------------------------------------------------------
1 | OAUTH_USERNAME=MISSING
2 | OAUTH_ACCESS_TOKEN=
3 | FITTABLE_SOURCE=faux
4 |
--------------------------------------------------------------------------------
/.eslintrc.yaml:
--------------------------------------------------------------------------------
1 |
2 | ---
3 | extends: [standard, standard-react]
4 | env:
5 | browser: true
6 | node: true
7 | es6: true
8 | ecmaFeatures:
9 | modules: true
10 | rules:
11 | camelcase: [2]
12 | no-var: [1]
13 | prefer-const: [1]
14 | comma-dangle: [2, always-multiline]
15 | jsx-quotes: [2, prefer-double]
16 | curly: [2, all]
17 | max-len: [2, 120, 2]
18 | no-extra-bind: [2]
19 | max-len: [2, 100, 2, {ignoreComments: true, ignoreUrls: true}]
20 | padded-blocks: [0]
21 | react/prop-types: [0]
22 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ### Environment configuration ###
2 | .env
3 |
4 | ### Node ###
5 | # Logs
6 | logs
7 | *.log
8 |
9 | # Runtime data
10 | pids
11 | *.pid
12 | *.seed
13 |
14 | # Dependency directory
15 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
16 | node_modules/*
17 |
18 | ### Sass ###
19 | .sass-cache
20 | *.css.map
21 |
22 | ### JetBrains ###
23 | *.iml
24 | .idea/
25 |
26 | ### Archives ###
27 | *.gz
28 | *.zip
29 |
30 | ### Windows ###
31 | # Windows image file caches
32 | Thumbs.db
33 | ehthumbs.db
34 |
35 | # Folder config file
36 | Desktop.ini
37 |
38 | # Recycle Bin used on file shares
39 | $RECYCLE.BIN/
40 |
41 | ### OSX ###
42 | .DS_Store
43 | .AppleDouble
44 | .LSOverride
45 |
46 | # Thumbnails
47 | ._*
48 |
49 | ### Linux ###
50 | *~
51 |
52 | # KDE directory preferences
53 | .directory
54 |
55 | ### Project-specific ###
56 |
57 | # Continuous deployment
58 | .deploy_key
59 |
60 | # Compiled files
61 | dist/
62 | .tmp
63 |
64 | # Temporary stuff
65 | tmp/
66 |
--------------------------------------------------------------------------------
/.gitlab-ci.yml:
--------------------------------------------------------------------------------
1 | image: gitlab.fit.cvut.cz:5000/ict/alpine-docker-images/ci:3.12
2 |
3 | stages:
4 | - build
5 | - test
6 | - deploy
7 |
8 | cache:
9 | key: "$CI_COMMIT_REF_SLUG" # per-branch cache
10 | paths:
11 | - .cache/
12 |
13 | before_script:
14 | - apk add build-base nodejs npm python2 yarn
15 | - export PKG_CACHE_PATH=".cache/pkg-cache"
16 | # GitLab CI does not cache anything outside the project directory.
17 | - yarn config set cache-folder $(pwd)/.cache/yarn
18 | - yarn install
19 |
20 | build:
21 | stage: build
22 | script:
23 | - yarn run build
24 | artifacts:
25 | paths:
26 | - dist/*
27 | expire_in: 1 month
28 |
29 | test:
30 | stage: test
31 | script:
32 | - yarn run lint
33 | - yarn run test:spec
34 |
35 | fit-pages:
36 | stage: deploy
37 | only:
38 | - master
39 | dependencies:
40 | - build
41 | before_script: []
42 | script:
43 | - ./script/pre-deploy
44 | environment:
45 | name: production
46 | url: https://timetable.fit.cvut.cz
47 | artifacts:
48 | paths:
49 | - public
50 | expire_in: 1 hour
51 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | dev/
2 | dist/fittable.js
3 | dist/fittable.min.js
4 | dist/fittable.min.css
5 | dist/*.map
6 | script/
7 | src/
8 | .tmp/
9 | .sass-cache/
10 | browserslists
11 | docs/
12 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | @cvut:registry=https://repository.fit.cvut.cz/npm
2 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - "8"
4 | sudo: false
5 | dist: trusty
6 | addons:
7 | apt:
8 | sources:
9 | - sourceline: deb https://dl.yarnpkg.com/debian/ stable main
10 | key_url: https://dl.yarnpkg.com/debian/pubkey.gpg
11 | packages:
12 | - yarn
13 | install:
14 | - yarn
15 | before_script:
16 | - yarn run build
17 | script:
18 | - yarn run lint
19 | - yarn run test:spec
20 | notifications:
21 | email: false
22 | slack:
23 | secure: hgkORyT7J3El7oJWlyXcMj8/nB3Wamfq6VFUaGMW2UMuTcgar5DXKB/Awnz/Ish2klgYx4icxKUBquLoEFc5hF5dWh7HxRpXAQWztoLs0RLwGIE12h1su5bt1h5UN1pIqGey+g8B0DddooCz7o8M1vwTATWq5mK1aCrjV3evQ0M=
24 | deploy:
25 | skip_cleanup: true
26 | provider: script
27 | script: script/travis-deploy
28 | on:
29 | branch: master
30 | env:
31 | global:
32 | - secure: UgTXgzJc9W9Ya3xH6N1jmRD7ojUOnWze5sNrr/7elAfC6asmERQJj9vK3A7Oby0DQekEB1WTH4iBA1uh8/cO/6G5GZqfoDq21acJVW5OtouPWwZSNc7/YUvMHiin7WlhBh9iGZBaZ0fzkaqrOv5daxhVApPwV3uFwECPey4HytA=
33 | - secure: SxxazkZH7nRNwUgu+JtQCu41MJkwZYtIz8bgwMRceUudNS/u44qXtBuz64rExoV8NHska0ISls6JlB9ReqiwlGi5nbbW37OkSZ+CcbVAsPQ9+KKCngmgJlVXKkdPZ9tD1av2M1w681tNnRGAiMJTwSyjPm8C7VkPdcL8hLRgBOU=
34 | - secure: iIC2f1+dO9EbXYAnwy9ffNRe0yfpJVHKGLTt7Dd1oxjm2BBPWdwLSrt3wpcJ/nqKVDbteC1Lfh7uiJnhcLOx56K4c5dpkJUSQGOx+kdlyHqFe7LAH6w9++IiOGvT8Ogqb+nA5j20HOkxoVHqgB+iiHNqNvpFxb/LoAEy1NWd3eA=
35 | cache:
36 | yarn: true
37 | directories:
38 | - node_modules
39 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License
2 |
3 | Copyright 2015-2016 Czech Technical University in Prague.
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
13 | all 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
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/appveyor.yml:
--------------------------------------------------------------------------------
1 | # Test against this version of Node.js
2 | environment:
3 | nodejs_version: "4"
4 |
5 | version: "{build}-{branch}"
6 |
7 | shallow_clone: true
8 |
9 | # Install scripts. (runs after repo cloning)
10 | install:
11 | # Get the latest stable version of Node.js or io.js
12 | - ps: Install-Product node $env:nodejs_version x64
13 | # install modules
14 | - npm install -g npm@3
15 | - set PATH=%APPDATA%\npm;%PATH%
16 | - npm install
17 |
18 | # Post-install test scripts.
19 | test_script:
20 | # Output useful info for debugging.
21 | - node --version
22 | - npm --version
23 | # run tests
24 | - npm test
25 |
26 | cache:
27 | - node_modules -> package.json
28 | - "%APPDATA%\\npm-cache -> package.json"
29 |
30 | # Don't actually build.
31 | build: off
32 | deploy: off
33 |
--------------------------------------------------------------------------------
/browserslists:
--------------------------------------------------------------------------------
1 | last 2 versions
2 | ie >= 9
3 | Android 3
4 |
--------------------------------------------------------------------------------
/doc/README.md:
--------------------------------------------------------------------------------
1 | Fittable slouží k přehlednému zobrazení výuky jako kalendáře. Oproti stávající aplikaci Timetable, která zobrazuje výuku jako 14denní statický rozvrh hodin, Fittable zohledňuje:
2 |
3 | * Změny ve výuce (např. kvůli státnímu svátku),
4 | * posuny hodin (např. v místnostech které se řídí časovým rozvrhem FA),
5 | * jednorázové události a zkoušky.
6 |
7 | Fittable je _client-side_ webová aplikace psaná v JavaScriptu. Data jsou čerpaná z aplikace [Sirius](https://github.com/cvut/sirius).
8 |
--------------------------------------------------------------------------------
/doc/api.md:
--------------------------------------------------------------------------------
1 | # Client-side API
2 |
3 | Fittable exposes some functionality for integration with native applications or client-side extensions. All methods are available in `window.fittable`.
4 |
5 | ## changeDate
6 |
7 | ```js
8 | changeDate(date: Date | string): void
9 | ```
10 |
11 | Changes displayed date to a new date. A date can be a Date object or date string in ISO format, e.g. `2017-03-30`
12 |
13 |
14 | ### Example
15 |
16 | ```js
17 | // Set date to January 30th, 2017
18 | window.fittable.changeDate('2017-01-30')
19 |
20 | // Set date to today's date
21 | window.fittable.changeDate(new Date())
22 | ```
23 |
24 | ## getCurrentDate
25 |
26 | ```js
27 | getCurrentDate(): Date
28 | ```
29 |
30 | Returns currently displayed date as Date object.
31 |
32 | ### Example
33 |
34 | ```js
35 | var d = window.fittable.getCurrentDate()
36 | console.log(d)
37 | // Prints:
38 | // Mon Mar 27 2017 02:00:00 GMT+0200 (CEST)
39 | ```
40 |
41 | ## getCurrentDateStr
42 |
43 | ```js
44 | getCurrentDateStr(): string
45 | ```
46 |
47 | Returns currently displayed date as Date object.
48 |
49 | ### Example
50 |
51 | ```js
52 | var s = window.fittable.getCurrentDateStr()
53 | console.log(s)
54 | // Prints:
55 | // 2017-03-27
56 | ```
57 |
--------------------------------------------------------------------------------
/doc/img/kos-attendance-list.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cvut/fittable/209389f08b01749cb81bf0d3215b7480354b9328/doc/img/kos-attendance-list.png
--------------------------------------------------------------------------------
/doc/pozadavky.md:
--------------------------------------------------------------------------------
1 | # Požadavky
2 |
3 | Datum: Leden 2015
4 | Autor: Jakub Jirůtka
5 |
6 | ## Funkční požadavky
7 |
8 | 1. bude poskytovat náhled na události (tj. kalendáře) z pohledu:
9 | 1. osoby,
10 | 2. předmětu,
11 | 3. místnosti.
12 | 2. bude poskytovat různé módy zobrazení:
13 | 1. rozvrhový (horizontální):
14 | a. s přepínáním mezi zobrazení 5/7 dní (např. dálkoví studenti mívají výuku i o víkendu),
15 | b. s časovou osou podle harmonogramu dané fakulty (_časové_ rozsahy) sloužící jako _pomocné_ vodítko.
16 | 2. kalendářní:
17 | 1. týdenní (výchozí),
18 | 2. _měsíční,_ (**P2**)
19 | 3. denní.
20 | 3. bude zobrazovat paritu (sudý/lichý) aktuálně vybraného období.
21 | 4. bude zobrazovat označení semestru aktuálně vybraného období.
22 | 5. okénko události bude obsahovat:
23 | 1. kód předmětu,
24 | 2. místnost (KOSí kód), (**P1**)
25 | 3. čas začátku a _konce události_ (**P2**),
26 | 4. typ události (barevným rozlišením),
27 | 5. příznak, zda byla událost ovlivněna rozvrhovou výjimkou (tj. změna oproti pravidelnému rozvrhu).
28 | 6. po kliknutí na okénko události se zobrazí detail události obsahující:
29 | 1. celý název předmětu nebo události,
30 | 2. odkaz na kalendář předmětu (jen pro P/C/L/J/Z),
31 | 3. pořadové číslo výukové hodiny nebo zkoušky v semestru (jen pro P/C/L/Z),
32 | 4. číslo paralelky (jen pro P/C/L/J),
33 | 5. odkaz na kalendář místnosti,
34 | 6. vyučující/zkoušející/organizátoři události (jeden nebo více lidí):
35 | 1. celé jméno,
36 | 2. e-mail (je na Usermapu),
37 | 3. odkaz na kalendář,
38 | 4. odkaz na Usermap.
39 | 7. seznam studentů v paralelce (jen pro P/C/L): (**P3**)
40 | 1. celé jméno,
41 | 2. uživatelské jméno,
42 | 3. zobrazovat ve „fancy pop-up.“
43 | 8. seznam výjimek, které událost ovlivnily.
44 | 7. na původním místě zrušené či přesunuté události bude zobrazovat zástupné okýnko:
45 | 1. v případě přesunuté události s odkazem na novou událost. (**P3**)
46 | 8. bude umožňovat navigaci v kalendáři po týdnech/měsících/semestrech.
47 | 9. bude poskytovat filtr zobrazených událostí:
48 | 1. podle typu události.
49 | 10. bude poskytovat jednotný vyhledávač kalendářů:
50 | 1. osoby (podle uživatelského i občanského jména),
51 | 2. předmětu (podle kódu a názvu),
52 | 3. místnosti (podle KOSího kódu).
53 | 11. kalendářní data bude získávat ze služby Sirius:
54 | 1. přes jeho RESTful API, ve formátu JSON,
55 | 2. _on-demand_.
56 | 12. bude poskytovat JS API pro:
57 | 1. přidání položek zobrazených v okénku události,
58 | 2. přidání položek zobrazených v detailu události (textových i akcí),
59 | 3. odchytávání akcí nad kalendářem [TODO].
60 |
61 | ## Nefunkční požadavky
62 |
63 | 1. Uživatelské rozhraní:
64 | 1. bude lokalizované do češtiny a angličtiny,
65 | 2. bude responzivní (od desktopu po mobilní telefon s WVGA),
66 | 3. bude přizpůsobené i pro ovládání na dotykovém displeji,
67 | 4. vizuálně bude vycházet z grafiky vytvořené pro „nový web“ od Josefa Lobotky (fonty, barvy, příp. ikony).
68 | 2. Přizpůsobitelnost a konfigurovatelnost:
69 | 1. widget bude možné přizpůsobit pro různá použití:
70 | 1. na fakultním webu pro studenty a vyučující,
71 | 2. v rámci administračního rozhraní pro rozvrháře,
72 | 3. v módu omezené funkčnosti, např. na osobní stránce vyučujícího, studenta atp.
73 | 2. widget bude počítat s nasazením i na dalších fakultách.
74 | 3. Dokumentace:
75 | 1. bude obsahovat kompletní postup pro sestavení (build),
76 | 2. bude obsahovat popis konfigurace a JS API pro přizpůsobení widgetu,
77 | 3. veškerá dokumentace by měla být v angličtině.
78 | 4. Kvalita kódu:
79 | 1. kód bude srozumitelný, dobře strukturovaný a potenciálně nejasné části okomentované,
80 | 2. kód bude pokrytý jednotkovými a integračními testy,
81 | 3. kód i další textové soubory budou v UTF-8 a s unixovým způsobem ukončování řádek (řídící znak LF / 0x0A),
82 | 3. názvy všech identifikátorů použitých v kódu budou v angličtině.
83 | 5. Verzování:
84 | 1. projekt bude verzovaný systémem Git na GitHubu.
85 | 6. Technologie:
86 | 1. bude implementovaný v jazyce kompilovaném do JavaScriptu; preferovaná je syntaxe ECMAScript 6+ (s překladem pomocí https://github.com/babel/babel[Babel]).
87 | 7. Běhové prostředí:
88 | 1. bude fungovat _client-side_, ve webovém prohlížeči s podporou JavaScriptu:
89 | 1. Chrome/Chromium 35+,
90 | 2. Firefox 31+,
91 | 3. Internet Explorer 10+ (9 alespoň omezeně),
92 | 4. Safari 6+.
93 | 8. Licence:
94 | 1. bude využívat výhradně _open-source_ knihovny.
95 |
96 | ## Zkratky
97 |
98 | Typy událostí:
99 |
100 | * přednáška [P]
101 | * cvičení [C]
102 | * laboratoř [L]
103 | * jednorázová akce předmětu [J]
104 | * zkouška (i zápočtová) [Z]
105 | * omezení vyučujícího [V]
106 | * obecná událost [O]
107 |
--------------------------------------------------------------------------------
/ictweb.yml:
--------------------------------------------------------------------------------
1 | name_cs: 'Fittable'
2 | description_cs: 'Aplikace pro zobrazení kalendáře výuky; technická a uživatelská dokumentace.'
3 | name_en: 'Fittable'
4 | description_en: 'Application for displaying study schedule; documentation for users and developers.'
5 | dir_index:
6 | - README.md
7 | doc_root: doc
8 | icon: icon.png
9 | keywords:
10 | - fittable
11 | - timetable
12 | - rozvrh
13 | - kalendář
14 | exclude:
15 | - img
16 | - include
17 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@cvut/fittable",
3 | "version": "0.8.1",
4 | "description": "Flexible calendar widget for fittable written in JavaScript",
5 | "main": "dist/app.js",
6 | "author": "Marián Hlaváč",
7 | "repository": {
8 | "type": "git",
9 | "url": "https://github.com/cvut/fittable"
10 | },
11 | "bugs": {
12 | "url": "https://github.com/cvut/fittable/issues"
13 | },
14 | "scripts": {
15 | "lint": "eslint src test --ext .js,.jsx",
16 | "clean": "rimraf dist/",
17 | "prebuild": "npm run clean",
18 | "build": "npm-run-all -p build:prod build:assets",
19 | "build:dev": "webpack -d",
20 | "build:prod": "webpack --optimize-minimize --config webpack.production.config.js --bail",
21 | "build:npm": "babel --out-dir dist src",
22 | "build:assets": "node script/build-static",
23 | "start": "npm run build:assets && webpack-dev-server --content-base dist/ --hot -d",
24 | "watch": "webpack -d --watch",
25 | "watch:npm": "babel -w --out-dir dist src",
26 | "test": "babel-tape-runner \"test/**/*.test.js\"",
27 | "test:watch": "chokidar --initial \"src/**/*.js\" \"src/**/*.js\" \"test/**/*.js\" -c \"npm test | tap-bail\"",
28 | "test:spec": "npm test | tap-spec"
29 | },
30 | "dependencies": {
31 | "babel-runtime": "^5.8.25",
32 | "camelize": "^1.0.0",
33 | "counterpart": "^0.17.0",
34 | "file-loader": "^0.8.4",
35 | "font-awesome": "^4.4.0",
36 | "frozen-moment": "^0.4.0",
37 | "hammerjs": "^2.0.4",
38 | "history": "^1.13.1",
39 | "moment": "^2.15.1",
40 | "moment-timezone": "^0.5.6",
41 | "ramda": "^0.19.0",
42 | "raven-js": "^2.3.0",
43 | "react": "^0.14.0",
44 | "react-addons-css-transition-group": "^0.14.3",
45 | "react-cookie": "^0.3.4",
46 | "react-dom": "^0.14.0",
47 | "react-redux": "^4.0.0",
48 | "react-router": "^1.0.0-rc3",
49 | "redux": "^3.0.4",
50 | "redux-localstorage": "^1.0.0-rc4",
51 | "redux-localstorage-filter": "^0.1.1",
52 | "redux-router": "^1.0.0-beta3",
53 | "redux-thunk": "^1.0.0"
54 | },
55 | "devDependencies": {
56 | "autoprefixer-loader": "^3.1.0",
57 | "babel": "^5.8.23",
58 | "babel-core": "^5.8.25",
59 | "babel-eslint": "^4.1.3",
60 | "babel-loader": "^5.3.2",
61 | "babel-tape-runner": "^1.2.0",
62 | "blue-tape": "^0.1.10",
63 | "chokidar-cli": "^1.1.0",
64 | "css-loader": "^0.21.0",
65 | "dotenv": "^1.2.0",
66 | "eslint": "^1.7.3",
67 | "eslint-config-standard": "^4.4.0",
68 | "eslint-config-standard-react": "^1.1.0",
69 | "eslint-plugin-react": "^3.6.3",
70 | "eslint-plugin-standard": "^1.3.1",
71 | "extract-text-webpack-plugin": "^0.8.2",
72 | "foundation-sites": "^5.5.3",
73 | "json-loader": "^0.5.3",
74 | "node-sass": "^4.14.1",
75 | "npm-run-all": "^1.2.12",
76 | "redux-logger": "^2.1.3",
77 | "rimraf": "^2.4.3",
78 | "sass-loader": "^3.0.0",
79 | "shelljs": "^0.5.3",
80 | "sinon": "^1.17.2",
81 | "style-loader": "^0.13.0",
82 | "tap-bail": "0.0.0",
83 | "tap-spec": "^4.1.0",
84 | "timekeeper": "0.0.5",
85 | "webpack": "^1.12.2",
86 | "webpack-dev-server": "^1.12.1"
87 | },
88 | "license": "MIT"
89 | }
90 |
--------------------------------------------------------------------------------
/script/build-static:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | require('shelljs/global')
3 |
4 | var baseDir = 'src'
5 | var targetDir = 'dist'
6 |
7 | var files = [
8 | baseDir + '/index.html',
9 | baseDir + '/landing.html',
10 | baseDir + '/error-unauthorized.html',
11 | baseDir + '/signpost.html',
12 | baseDir + '/images',
13 | baseDir + '/locales',
14 | ]
15 |
16 | mkdir('-p', targetDir)
17 | cp('-Rf', files, targetDir)
18 |
--------------------------------------------------------------------------------
/script/pre-deploy:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | # This script is temporary ugly workaround.
3 | set -eu
4 |
5 | cd "$(dirname "$0")/.."
6 |
7 | mkdir -p public
8 | cp -a dist public/new
9 |
10 | cd public
11 | mv new/signpost.html index.html
12 |
13 | md5js=$(md5sum new/fittable.js | cut -d' ' -f1)
14 | mv new/fittable.js new/fittable-$md5js.js
15 |
16 | md5css=$(md5sum new/fittable.css | cut -d' ' -f1)
17 | mv new/fittable.css new/fittable-$md5css.css
18 |
19 | sed -i "s|fittable.js|fittable-$md5js.js|" new/index.html
20 | sed -i "s|fittable.css|fittable-$md5css.css|" new/*.html
21 | sed -i "s|fittable.css|new/fittable-$md5css.css|" index.html
22 | sed -i 's|"_oauth/login"|"/_oauth/login?original_uri=/new/"|' new/landing.html
23 | sed -i 's|| \n |g' new/index.html
24 |
--------------------------------------------------------------------------------
/script/travis-deploy:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Exit if any statement returns a non-true return value.
4 | set -e
5 |
6 | if [[ "$TRAVIS_PULL_REQUEST" != 'false' ]]; then
7 | echo 'This is a pull request, skipping deploy.'; exit 0
8 | fi
9 |
10 | if [[ -z "$REMOTE" ]]; then
11 | echo '$REMOTE is not set, skipping deploy.'; exit 0
12 | fi
13 |
14 | if [[ "$TRAVIS_BRANCH" != 'master' ]]; then
15 | echo 'This is not the master branch, skipping deploy.'; exit 0
16 | fi
17 |
18 | if [[ "$TRAVIS_BUILD_NUMBER.1" != "$TRAVIS_JOB_NUMBER" ]]; then
19 | echo 'This is not a first job of the build, skipping deploy.'; exit 0
20 | fi
21 |
22 | openssl aes-256-cbc -K $encrypted_97a5f01c84d8_key -iv $encrypted_97a5f01c84d8_iv -in .deploy_key.enc -out .deploy_key -d
23 | chmod 600 .deploy_key
24 |
25 | echo "Deploying to the remote server."
26 | scp -Br -i .deploy_key -o StrictHostKeyChecking=no dist/* $REMOTE:/var/www/sirius/fittable-dev/
27 |
--------------------------------------------------------------------------------
/src/Root.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { Provider } from 'react-redux'
3 |
4 | import createStore from './store'
5 | import FittableContainer from './containers/FittableContainer'
6 | import * as callbacks from './callbacks'
7 | import { browserLanguage } from './client'
8 | import { initialState as settingsInitialState } from './reducers/settingsReducer'
9 |
10 | const initialState = {
11 | settings: {
12 | ...settingsInitialState,
13 | locale: browserLanguage(window.navigator.userLanguage || window.navigator.language),
14 | },
15 | }
16 | const store = createStore(initialState)
17 |
18 | export default class Root extends Component {
19 |
20 | render () {
21 | return (
22 |
23 |
24 |
25 | )
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/actions/clientActions.js:
--------------------------------------------------------------------------------
1 | import { CLIENT_CHANGE } from '../constants/actionTypes'
2 | import {
3 | SMALL_SCREEN, SMALL_SCREEN_BREAKPOINT, MEDIUM_SCREEN,
4 | MEDIUM_SCREEN_BREAKPOINT, LARGE_SCREEN,
5 | } from '../constants/screenSizes'
6 |
7 | function isSmallScreen () {
8 | return global.window.innerWidth < SMALL_SCREEN_BREAKPOINT
9 | }
10 |
11 | function isMediumScreen () {
12 | return global.window.innerWidth < MEDIUM_SCREEN_BREAKPOINT
13 | }
14 |
15 | function clientChange (screenSize) {
16 | return {
17 | type: CLIENT_CHANGE,
18 | payload: { screenSize },
19 | }
20 | }
21 |
22 | export function detectScreenSize () {
23 | return function windowResizeThunk (dispatch, getState) {
24 | const {client} = getState()
25 |
26 | let screenSize = LARGE_SCREEN
27 | if (isSmallScreen()) {
28 | screenSize = SMALL_SCREEN
29 | } else if (isMediumScreen()) {
30 | screenSize = MEDIUM_SCREEN
31 | }
32 |
33 | if (client.screenSize !== screenSize) {
34 | dispatch(clientChange(screenSize))
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/actions/dataActions.js:
--------------------------------------------------------------------------------
1 | import { isoWeekRange } from '../date'
2 | import { invertLinkNames } from '../dataManipulation'
3 | import {
4 | EVENTS_LOAD_STARTED,
5 | EVENTS_LOAD_COMPLETED,
6 | EVENTS_LOAD_FAILED,
7 | DATA_ERROR_HIDE,
8 | } from '../constants/actionTypes'
9 |
10 | function startEventsRequest () {
11 | return {
12 | type: EVENTS_LOAD_STARTED,
13 | }
14 | }
15 |
16 | function receiveEvents (events, linkNames) {
17 | return {
18 | type: EVENTS_LOAD_COMPLETED,
19 | payload: {
20 | events,
21 | linkNames,
22 | },
23 | }
24 | }
25 |
26 | function receiveError (errorObject) {
27 | return {
28 | type: EVENTS_LOAD_FAILED,
29 | payload: errorObject,
30 | }
31 | }
32 |
33 | export function fetchEvents (dataCallback, calendar) {
34 | const {id: calendarId, type: calendarType, date} = calendar
35 | const [dateFrom, dateTo] = isoWeekRange(date)
36 |
37 | return function (dispatch) {
38 | // First dispatch: inform state that loading is going on
39 | dispatch(startEventsRequest())
40 |
41 | dataCallback({calendarId, calendarType, dateFrom, dateTo}, function (error, result) {
42 | if (error) {
43 | return dispatch(receiveError(error))
44 | }
45 | const { events, linkNames } = result
46 | // We have (hopefully) received data by now
47 | // FIXME: add error handling
48 | dispatch(receiveEvents(events, invertLinkNames(linkNames)))
49 | })
50 | }
51 | }
52 |
53 | export function hideDataError () {
54 | return {
55 | type: DATA_ERROR_HIDE,
56 | payload: {},
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/actions/filterActions.js:
--------------------------------------------------------------------------------
1 | import { DISPLAY_FILTERS_CHANGE } from '../constants/actionTypes'
2 |
3 | export function changeDisplayFilters (payload) {
4 | return {
5 | type: DISPLAY_FILTERS_CHANGE,
6 | displayFilters: payload,
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/src/actions/linkActions.js:
--------------------------------------------------------------------------------
1 | import { pushState } from 'redux-router'
2 |
3 | const ENTITY_TYPE_PLURAL = {
4 | course: 'courses',
5 | person: 'people',
6 | room: 'rooms',
7 | }
8 |
9 | function entityType (type) {
10 | return ENTITY_TYPE_PLURAL[type] || type
11 | }
12 |
13 | export function calendarUrl ({id, date, type = null}) {
14 | const urlType = entityType(type)
15 | const dateQuery = date ? `?date=${date}` : ''
16 |
17 | let path = `${urlType}/${id}`
18 | if (urlType === 'people' && id === 'me') {
19 | path = ''
20 | }
21 |
22 | return `${path}${dateQuery}`
23 | }
24 |
25 | export function changeCalendar (calendar) {
26 | // XXX: state object is required by history
27 | // https://github.com/rackt/history/blob/master/docs/GettingStarted.md#navigation
28 | return pushState(null, calendarUrl(calendar))
29 | }
30 |
--------------------------------------------------------------------------------
/src/actions/searchActions.js:
--------------------------------------------------------------------------------
1 | import { SEARCH_REQUEST, SEARCH_RESPONSE, SEARCH_CLEAR } from '../constants/actionTypes'
2 |
3 | function startSearchRequest (query) {
4 | return {
5 | type: SEARCH_REQUEST,
6 | payload: { query },
7 | }
8 | }
9 |
10 | function receiveSearchResults (results) {
11 | return {
12 | type: SEARCH_RESPONSE,
13 | payload: { results },
14 | }
15 | }
16 |
17 | export function clearSearchResults () {
18 | return {
19 | type: SEARCH_CLEAR,
20 | }
21 | }
22 |
23 | export function fetchSearchResults (searchCallback, query) {
24 | return function searchResultsDispatcher (dispatch) {
25 | if (!query) {
26 | return dispatch(receiveSearchResults([]))
27 | }
28 | // First dispatch: inform state that request has been sent
29 | dispatch(startSearchRequest(query))
30 |
31 | searchCallback(query, results => {
32 | // FIXME: add error handling
33 | dispatch(receiveSearchResults(results))
34 | })
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/actions/semesterActions.js:
--------------------------------------------------------------------------------
1 | import { SEMESTER_LOAD_COMPLETED } from '../constants/actionTypes'
2 | import { findSemester, convertRawSemester, dateInSemester } from '../semester'
3 |
4 | function receiveSemesterData (semester) {
5 | return {
6 | type: SEMESTER_LOAD_COMPLETED,
7 | payload: semester,
8 | }
9 | }
10 |
11 | export function invalidateSemesterData (semester) {
12 | return {
13 | ...semester,
14 | valid: false,
15 | }
16 | }
17 |
18 | export function fetchSemesterData (semesterCallback, date) {
19 | return function semesterDataThunk (dispatch, getState) {
20 | const {semester} = getState()
21 | if (semester && semester.valid && dateInSemester(semester, date)) {
22 | return
23 | }
24 |
25 | semesterCallback(data => {
26 | if (!data) {
27 | dispatch(receiveSemesterData(invalidateSemesterData(semester)))
28 | return
29 | }
30 |
31 | const currentSemester = findSemester(data, date)
32 | if (!currentSemester) {
33 | dispatch(receiveSemesterData(invalidateSemesterData(semester)))
34 | return
35 | }
36 |
37 | dispatch(receiveSemesterData(convertRawSemester(currentSemester)))
38 | })
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/actions/settingsActions.js:
--------------------------------------------------------------------------------
1 | import { SETTINGS_CHANGE } from '../constants/actionTypes'
2 |
3 | export function changeSettings (payload) {
4 | return {
5 | type: SETTINGS_CHANGE,
6 | settings: payload,
7 | }
8 | }
9 |
10 | export const setLocale = (newLocale) => changeSettings({locale: newLocale})
11 | export const setLayout = (layout) => changeSettings({layout: layout})
12 | export const setEventsColors = (enabled) => changeSettings({eventsColors: enabled})
13 | export const setFullWeek = (enabled) => changeSettings({fullWeek: enabled})
14 | export const setFacultyGrid = (enabled) => changeSettings({facultyGrid: enabled})
15 |
--------------------------------------------------------------------------------
/src/actions/uiActions.js:
--------------------------------------------------------------------------------
1 | import { SIDEBAR_DISPLAY, EVENT_DISPLAY } from '../constants/actionTypes'
2 |
3 | export function displaySidebar (sidebar) {
4 | return {
5 | type: SIDEBAR_DISPLAY,
6 | payload: { sidebar },
7 | }
8 | }
9 |
10 | export function displayEvent (eventId) {
11 | return {
12 | type: EVENT_DISPLAY,
13 | payload: { eventId },
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/actions/userActions.js:
--------------------------------------------------------------------------------
1 | import { USER_LOAD_STARTED, USER_LOAD_COMPLETED } from '../constants/actionTypes'
2 | import { fetchUserCallback, logoutUserCallback } from '../callbacks'
3 |
4 | function startUserRequest () {
5 | return {
6 | type: USER_LOAD_STARTED,
7 | }
8 | }
9 |
10 | function receiveUser (payload) {
11 | return {
12 | type: USER_LOAD_COMPLETED,
13 | payload,
14 | }
15 | }
16 |
17 | export function fetchUserData () {
18 | return function thunk (dispatch) {
19 | dispatch(startUserRequest())
20 |
21 | fetchUserCallback((error, result) => {
22 | if (error) {
23 | return dispatch(receiveUser(null))
24 | }
25 | dispatch(receiveUser(result))
26 | })
27 | }
28 | }
29 |
30 | export function logoutUser () {
31 | return function thunk () {
32 | logoutUserCallback(error => {
33 | if (error) {
34 | console.error(error)
35 | global.alert(error.message)
36 | } else {
37 | global.location.href = 'landing.html'
38 | }
39 | })
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/app.js:
--------------------------------------------------------------------------------
1 | import Raven from 'raven-js'
2 |
3 | if (process.env.SENTRY_DSN && process.env.NODE_ENV === 'production') {
4 | Raven.config(process.env.SENTRY_DSN).install()
5 | }
6 |
7 | import React from 'react'
8 | import ReactDOM from 'react-dom'
9 |
10 | import Counterpart from 'counterpart'
11 |
12 | import 'moment/locale/cs'
13 | import LocaleCS from './locales/cs.json'
14 | import LocaleEN from './locales/en.json'
15 |
16 | import Root from './Root'
17 |
18 | const rootElement = document.getElementById('fittable')
19 |
20 | // Register translations
21 | Counterpart.registerTranslations('en', LocaleEN)
22 | Counterpart.registerTranslations('cs', Object.assign(LocaleCS, {
23 | counterpart: {
24 | pluralize: (entry, count) => {
25 | entry[ (count === 0 && 'zero' in entry) ? 'zero' : (count === 1) ? 'one' : 'other' ]
26 | },
27 | },
28 | }))
29 | // Counterpart.setLocale(options.locale)
30 | // Moment.locale(options.locale)
31 |
32 | ReactDOM.render( , rootElement)
33 |
--------------------------------------------------------------------------------
/src/callbacks/index.js:
--------------------------------------------------------------------------------
1 | // XXX: CommonJS for conditional import
2 | if (process.env.FITTABLE_SOURCE === 'sirius') {
3 | module.exports = require('./sirius')
4 | } else {
5 | module.exports = require('./faux')
6 | }
7 |
--------------------------------------------------------------------------------
/src/client.js:
--------------------------------------------------------------------------------
1 | import { is } from 'ramda'
2 |
3 | /**
4 | * Detects if there is an information about browser language.
5 | * If not, returns 'cs'. If there is the information and the language
6 | * string starts with 'cs', returns 'cs', otherwise 'en'.
7 | */
8 | export function browserLanguage (navigatorLanguage = 'cs') {
9 | if (is(String, navigatorLanguage) && navigatorLanguage.toLowerCase().substr(0, 2) === 'cs') {
10 | return 'cs'
11 | }
12 | return 'en'
13 | }
14 |
--------------------------------------------------------------------------------
/src/components/Controls.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Wraps all controls displayed in the heading of widget.
3 | * Contains week controllers and functions tools.
4 | */
5 |
6 | import React, { PropTypes } from 'react'
7 | import { isScreenMediumAndUp } from '../screen'
8 |
9 | import SemesterWeek from './SemesterWeek'
10 | import WeekNav from './WeekNav'
11 | import FunctionsBar from './FunctionsBar'
12 | import WeekSwitcher from './WeekSwitcher'
13 | import { shiftDate } from '../date'
14 | import { semester as semesterType } from '../constants/propTypes'
15 |
16 | const propTypes = {
17 | onDateChange: PropTypes.func.isRequired,
18 | onSettingsPanelChange: PropTypes.func.isRequired,
19 | viewDate: React.PropTypes.instanceOf(Date),
20 | days7: PropTypes.bool,
21 | semester: semesterType,
22 | }
23 |
24 | class Controls extends React.Component {
25 |
26 | /**
27 | * Handles WeekNav's previous button click event
28 | */
29 | handlePrevClick () {
30 | const shiftFun = shiftDate(this.props.viewDate)
31 | const skipWeekend = !this.props.days7 && this.props.viewDate.getDay() === 1
32 |
33 | let shiftBy
34 |
35 | if (isScreenMediumAndUp(this.props.screenSize)) {
36 | shiftBy = shiftFun('week', -1)
37 | } else {
38 | shiftBy = shiftFun('day', skipWeekend ? -3 : -1)
39 | }
40 | this.props.onDateChange(shiftBy)
41 | }
42 |
43 | /**
44 | * Handles WeekNav's next button click event
45 | */
46 | handleNextClick () {
47 | const shiftFun = shiftDate(this.props.viewDate)
48 | const skipWeekend = !this.props.days7 && this.props.viewDate.getDay() === 5
49 |
50 | let shiftBy
51 |
52 | if (isScreenMediumAndUp(this.props.screenSize)) {
53 | shiftBy = shiftFun('week', +1)
54 | } else {
55 | shiftBy = shiftFun('day', skipWeekend ? +3 : +1)
56 | }
57 | this.props.onDateChange(shiftBy)
58 | }
59 |
60 | /**
61 | * Handles a click on a week row in the WeekSelector
62 | */
63 | handleWeekClick () {
64 | this.refs.weekSwitcher.toggle()
65 | }
66 |
67 | render () {
68 |
69 | return (
70 |
71 |
78 |
84 |
88 |
89 |
90 | )
91 | }
92 | }
93 |
94 | Controls.propTypes = propTypes
95 |
96 | export default Controls
97 |
--------------------------------------------------------------------------------
/src/components/Day.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Component representing one row (day) in timetable.
3 | */
4 |
5 | import React, { PropTypes } from 'react'
6 | import moment from 'moment'
7 |
8 | const propTypes = {
9 | date: PropTypes.instanceOf(Date),
10 | viewDate: PropTypes.instanceOf(Date),
11 | }
12 |
13 | class Day extends React.Component {
14 |
15 | render () {
16 | const date = moment(this.props.date)
17 | const isToday = date.isSame(moment(), 'days')
18 | const isSelected = date.isSame(this.props.viewDate, 'days')
19 | const className = `day ${isToday ? 'active' : ''} ${isSelected ? 'selected' : ''}`
20 |
21 | return (
22 |
23 |
24 |
25 | {date.date()}
26 | {date.format('dddd')}
27 |
28 |
29 |
30 | {this.props.children}
31 |
32 |
33 | )
34 | }
35 | }
36 |
37 | Day.propTypes = propTypes
38 |
39 | export default Day
40 |
--------------------------------------------------------------------------------
/src/components/Footer.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import CP from 'counterpart'
3 |
4 | import LogoutButton from './LogoutButton'
5 |
6 | export default function Footer ({ userName, onLogout }) {
7 | return (
8 |
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/src/components/FunctionFilter.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import R from 'ramda'
3 | import CP from 'counterpart'
4 |
5 | // Types of filterable events.
6 | const eventTypes = [
7 | 'lecture', 'tutorial', 'laboratory', 'exam', 'assessment', 'course_event',
8 | 'teacher_timetable_slot', 'other',
9 | ]
10 |
11 | /**
12 | * Provides ability to filter out some types of events from timetable.
13 | *
14 | * @param {Object} options.displayFilter State of the filters.
15 | * @param {Function} options.onFilterChange Function to be called on a filter change.
16 | */
17 | function FunctionFilter ({ displayFilter, onFilterChange }) {
18 |
19 | // Handles click on a filter button.
20 | function handleToggleFilter (filterName) {
21 | const oldState = displayFilter[filterName]
22 | onFilterChange({ [filterName]: !oldState })
23 | }
24 |
25 | const filterButton = (filterName) => (
26 | handleToggleFilter(filterName) }
30 | >
31 | { CP.translate('event_type.' + filterName) }
32 |
33 | )
34 |
35 | return (
36 |
37 |
38 |
{ CP.translate('functions.filter.heading') }
39 |
40 | { R.map(filterButton, eventTypes) }
41 |
42 |
43 |
44 | )
45 | }
46 |
47 | export default FunctionFilter
48 |
--------------------------------------------------------------------------------
/src/components/FunctionsBar.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Component wrapping all function control buttons located in upper right corner of the widget.
3 | */
4 |
5 | import React, { PropTypes } from 'react'
6 | import CP from 'counterpart'
7 |
8 | const propTypes = {
9 | onPanelToggle: PropTypes.func,
10 | }
11 |
12 | class FunctionsBar extends React.Component {
13 |
14 | /**
15 | * Handles a click on the search icon
16 | */
17 | handleSearchClick () {
18 | this.props.onPanelToggle('search')
19 |
20 | // Focus the search input
21 | setTimeout(() => {
22 | document.getElementById('searchinput').focus()
23 | }, 1000)
24 | }
25 |
26 | onFunctionClick (functionName) {
27 | this.props.onPanelToggle(functionName)
28 | }
29 |
30 | render () {
31 | return (
32 |
33 |
38 | { CP.translate('functions.ical.name') }
39 |
40 |
41 |
46 | { CP.translate('functions.filter.name') }
47 |
48 |
49 |
55 | { CP.translate('functions.settings.name') }
56 |
57 |
58 |
59 |
60 | )
61 | }
62 | }
63 |
64 | FunctionsBar.propTypes = propTypes
65 |
66 | export default FunctionsBar
67 |
--------------------------------------------------------------------------------
/src/components/FunctionsSidebar.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Component wrapping function panels
3 | */
4 |
5 | import React, { PropTypes } from 'react'
6 |
7 | import { options as optionsType } from '../constants/propTypes'
8 | import FunctionSettings from './FunctionSettings'
9 | import FunctionFilter from './FunctionFilter'
10 | import SidebarIcal from './SidebarIcal'
11 |
12 | const propTypes = {
13 | opened: PropTypes.oneOf(['settings', 'search', 'filter', 'ical']),
14 | onSettingsChange: PropTypes.func,
15 | settings: PropTypes.shape(optionsType),
16 | displayFilter: PropTypes.objectOf(PropTypes.bool), // FIXME: shared type
17 | onFilterChange: PropTypes.func,
18 | user: PropTypes.shape({
19 | id: PropTypes.string,
20 | name: PropTypes.string,
21 | publicAccessToken: PropTypes.string,
22 | }),
23 | }
24 |
25 | class FunctionsSidebar extends React.Component {
26 |
27 | render () {
28 | let functionToRender
29 |
30 | if (this.props.opened === 'settings') {
31 | functionToRender = (
32 |
37 | )
38 | }
39 | if (this.props.opened === 'filter') {
40 | functionToRender = (
41 |
45 | )
46 | }
47 |
48 | if (this.props.opened === 'ical') {
49 | const {id, publicAccessToken} = this.props.user
50 | functionToRender = (
51 |
52 | )
53 | }
54 |
55 | const className = `functions-sidebar ${this.props.opened !== null ? '' : 'hide'}`
56 |
57 | return (
58 |
59 |
60 | {functionToRender}
61 |
62 |
63 | )
64 | }
65 | }
66 |
67 | FunctionsSidebar.propTypes = propTypes
68 |
69 | export default FunctionsSidebar
70 |
--------------------------------------------------------------------------------
/src/components/Grid.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Component for drawing timetable's grid using JS-generated SVG image
3 | */
4 |
5 | import React, { PropTypes } from 'react'
6 | import R from 'ramda'
7 |
8 | const propTypes = {
9 | horizontal: PropTypes.bool,
10 | hours: PropTypes.number.isRequired,
11 | offset: PropTypes.number.isRequired,
12 | color: PropTypes.string.isRequired,
13 | }
14 |
15 | class Grid extends React.Component {
16 |
17 | createLine (type, hours, offset, color) {
18 | const defaults = {
19 | className: 'Grid-line',
20 | x1: '0',
21 | y1: '0',
22 | x2: '100%',
23 | y2: '100%',
24 | stroke: color,
25 | strokeWidth: 1,
26 | }
27 |
28 | return (n) => {
29 | const pos = ((n + offset) / hours * 100 + 0.1) + '%'
30 | // Position percentage +0.1% to avoid cropping the first line
31 |
32 | const inheritedParams = {
33 | ...defaults,
34 | key: n,
35 | }
36 |
37 | if (type === 'vertical') {
38 | return
39 | } else {
40 | return
41 | }
42 | }
43 | }
44 |
45 | getLines () {
46 | const hours = Math.ceil(this.props.hours)
47 | const type = this.props.horizontal ? 'horizontal' : 'vertical'
48 |
49 | return R.times(
50 | this.createLine(type, this.props.hours, this.props.offset, this.props.color),
51 | hours
52 | )
53 | }
54 |
55 | render () {
56 | return (
57 |
58 | {this.getLines(this)}
59 |
60 | )
61 | }
62 |
63 | }
64 |
65 | Grid.propTypes = propTypes
66 |
67 | export default Grid
68 |
--------------------------------------------------------------------------------
/src/components/Header.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import CP from 'counterpart'
3 |
4 | function calendarHeader ({type, id}) {
5 | if (type === 'people' && id === 'me') {
6 | return `${CP.translate('calendarType.personal')}`
7 | }
8 |
9 | const cpKey = `calendarType.${type}`
10 | return `${CP.translate(cpKey)} ${id}`
11 | }
12 |
13 | export default function Header ({calendar, semesterName, children}) {
14 | return (
15 |
16 | {children}
17 |
18 | {calendarHeader(calendar)}
19 | {semesterName}
20 |
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/HourLabel.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export default function HourLabel ({position, length, layout, children}) {
4 | let style
5 | if (layout === 'horizontal') {
6 | style = {
7 | left: position * 100 + '%',
8 | width: length * 100 + '%',
9 | }
10 | } else {
11 | style = {
12 | top: position * 100 + '%',
13 | height: length * 100 + '%',
14 | }
15 | }
16 |
17 | return (
18 |
19 | {children}
20 |
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/LogoutButton.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import CP from 'counterpart'
3 |
4 | export default function LogoutButton ({ onClick }) {
5 | return (
6 |
7 |
8 | {CP.translate('logout')}
9 |
10 | )
11 | }
12 |
--------------------------------------------------------------------------------
/src/components/NowIndicator.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Indicator showing line representing actual point of today's time
3 | */
4 |
5 | import React from 'react'
6 | import moment from 'moment'
7 | import { isScreenMediumAndUp } from '../screen'
8 | import { weekdayNum } from '../date'
9 |
10 | function NowIndicator ({
11 | currentDate, timeline, viewDate, days7, screenSize, horizontalLayout }) {
12 |
13 | const now = moment(currentDate)
14 |
15 | const startOfToday = now.clone().startOf('day').add(timeline.start, 'seconds')
16 | // Distance from start of timeline in seconds
17 | const nowSeconds = now.diff(startOfToday, 'seconds')
18 |
19 | const dayWidth = 1 / (days7 ? 7 : 5)
20 | const length = nowSeconds / timeline.duration
21 | const currentWeekday = now.isoWeekday() - 1
22 | const offset = currentWeekday * dayWidth
23 | const displayMultipleDays = isScreenMediumAndUp(screenSize)
24 |
25 | const selectedDay = weekdayNum(viewDate)
26 |
27 | // Indicator will be shown if:
28 | // - it is not bleeding out of the timeline (i.e. length and offset are within some percentage)
29 | // - we are displaying current week
30 | let shown = (length > 0 && length < 1 && offset < 1) &&
31 | now.isSame(viewDate, 'isoWeek')
32 |
33 | if (displayMultipleDays && currentWeekday !== selectedDay) {
34 | shown = false
35 | }
36 |
37 | if (!shown) {
38 | // XXX: stateless components cannot return null or false (for now)
39 | return
40 | }
41 |
42 | if (displayMultipleDays && horizontalLayout) {
43 | return (
44 |
53 | )
54 | } else {
55 | return (
56 |
65 | )
66 | }
67 | }
68 |
69 | export default NowIndicator
70 |
--------------------------------------------------------------------------------
/src/components/PeriodicUpdate.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * High-order component which periodically updates
3 | */
4 | import { createClass, PropTypes, Children, cloneElement } from 'react'
5 | import { now } from '../date'
6 |
7 | const PeriodicUpdate = createClass({
8 | propTypes: {
9 | period: PropTypes.number,
10 | children: PropTypes.element.isRequired,
11 | },
12 |
13 | displayName: 'PeriodicUpdate',
14 |
15 | getDefaultProps: () => ({
16 | period: 1000, // period in ms for setTimeout
17 | }),
18 |
19 | getInitialState: () => ({
20 | currentDate: now(),
21 | timeoutObject: null,
22 | }),
23 |
24 | componentDidMount () {
25 | this.setState({ timeoutObject: global.setTimeout(this.updateDate, this.props.period) })
26 | },
27 |
28 | componentWillUnmount () {
29 | const { timeoutObject } = this.state
30 | if (timeoutObject) {
31 | global.clearTimeout(timeoutObject)
32 | }
33 | },
34 |
35 | updateDate () {
36 | this.setState({
37 | currentDate: now(),
38 | timeoutObject: global.setTimeout(this.updateDate, this.props.period),
39 | })
40 | },
41 |
42 | render () {
43 | const { children } = this.props
44 | const { currentDate } = this.state
45 |
46 | const child = Children.only(children)
47 | return cloneElement(child, { currentDate })
48 | },
49 | })
50 |
51 | export default PeriodicUpdate
52 |
--------------------------------------------------------------------------------
/src/components/PositionedExpander.jsx:
--------------------------------------------------------------------------------
1 | import { createClass, PropTypes, cloneElement } from 'react'
2 | import { findDOMNode } from 'react-dom'
3 | import safeExpandingDirection from '../utils/safeExpandingDirection'
4 | import R from 'ramda'
5 |
6 | import { EVENT_MAX_WIDTH, EVENT_MAX_HEIGHT } from '../constants/events'
7 |
8 | function getExpandingDirection (ref, defaultDir) {
9 | const expandableDOM = findDOMNode(ref)
10 |
11 | const point = expandableDOM
12 | ? R.props(['left', 'top'], expandableDOM.getBoundingClientRect())
13 | : [0, 0]
14 |
15 | return safeExpandingDirection(
16 | point,
17 | [EVENT_MAX_WIDTH, EVENT_MAX_HEIGHT],
18 | global,
19 | defaultDir)
20 | }
21 |
22 | const PositionedExpander = createClass({
23 |
24 | propTypes: {
25 | expanded: PropTypes.bool,
26 | left: PropTypes.number,
27 | top: PropTypes.number,
28 | children: PropTypes.element.isRequired,
29 | },
30 |
31 | displayName: 'PositionedExpander',
32 |
33 | defaultExpanding: { right: true, bottom: true },
34 |
35 | getDefaultProps: () => ({
36 | expanded: false,
37 | }),
38 |
39 | getInitialState () {
40 | return { expandingDirection: this.defaultExpanding }
41 | },
42 |
43 | componentWillReceiveProps ({ expanded }) {
44 | let expandingDirection = this.defaultExpanding
45 | if (expanded && this.refs && this.refs.expandable) {
46 | expandingDirection = getExpandingDirection(this.refs.expandable, this.defaultExpanding)
47 | }
48 |
49 | this.setState({ expandingDirection })
50 | },
51 |
52 | render () {
53 | const expandingDir = this.state.expandingDirection
54 |
55 | const childrenProps = {
56 | expanded: this.props.expanded,
57 | align: {
58 | top: expandingDir.bottom,
59 | left: expandingDir.right,
60 | },
61 | ref: 'expandable',
62 | }
63 |
64 | return cloneElement(this.props.children, childrenProps)
65 | },
66 | })
67 |
68 | export default PositionedExpander
69 |
--------------------------------------------------------------------------------
/src/components/Search.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Function component, search function
3 | * Provides ability to search events, teachers etc.
4 | */
5 |
6 | import React, { PropTypes } from 'react'
7 | import CP from 'counterpart'
8 |
9 | const propTypes = {
10 | onSearch: PropTypes.func,
11 | onViewChange: PropTypes.func,
12 | onClear: PropTypes.func,
13 | searchResults: PropTypes.arrayOf(PropTypes.shape({
14 | id: PropTypes.number,
15 | type: PropTypes.string,
16 | title: PropTypes.string,
17 | })),
18 | }
19 |
20 | class Search extends React.Component {
21 |
22 | constructor (props) {
23 |
24 | super(props)
25 | this.autoSearchTimeout = null
26 | }
27 |
28 | /**
29 | * Handles a form submit, making the search
30 | * @returns {boolean}
31 | */
32 | handleSearch (e) {
33 |
34 | this.props.onSearch(this.refs.searchquery.value)
35 | e.preventDefault()
36 | }
37 |
38 | handleResultClick (type, id) {
39 | this.props.onViewChange(type, id)
40 | this.props.onClear()
41 | this.refs.searchquery.getDOMNode().value = ''
42 | }
43 |
44 | handleInputKeyup (e) {
45 | if (e.target.value.length >= 3) {
46 | if (this.autoSearchTimeout !== null) {
47 | clearTimeout(this.autoSearchTimeout)
48 | }
49 |
50 | this.autoSearchTimeout = setTimeout(() => {
51 | this.props.onSearch(this.refs.searchquery.value)
52 | }, 100)
53 | } else {
54 | this.props.onClear()
55 | }
56 | }
57 |
58 | componentDidMount () {
59 | this.refs.searchquery.addEventListener('keyup', this.handleInputKeyup.bind(this))
60 | }
61 |
62 | componentWillUnmount () {
63 | this.refs.searchquery.removeEventListener('keyup', this.handleInputKeyup.bind(this))
64 | }
65 |
66 | render () {
67 | const searchResultsClass = 'Search-results' +
68 | (this.props.searchResults.length ? ' is-active' : '')
69 |
70 | return (
71 |
72 |
73 |
87 |
88 |
89 |
90 | {this.props.searchResults.map(function (result) {
91 | return (
92 |
93 |
98 | {'title' in result ? result.title : result.id}
99 |
100 | {'title' in result ? result.id : ''}
101 |
102 |
103 |
104 | )
105 | }.bind(this)) }
106 |
107 |
108 |
109 | )
110 | }
111 | }
112 |
113 | Search.propTypes = propTypes
114 |
115 | export default Search
116 |
--------------------------------------------------------------------------------
/src/components/SemesterWeek.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import R from 'ramda'
3 | import { findWeekByDate } from '../semesterWeeks'
4 | import { translate } from '../locale'
5 |
6 | // @sig Week -> String -> String
7 | const translateWeekName = R.curry((week, type) => {
8 | return translate(`semesterWeek.${type}`, {
9 | weekNum: week.teachingWeek || '?',
10 | parity: translate(week.parity, { fallback: '' }),
11 | })
12 | })
13 |
14 | // @sig Week -> String
15 | const displayName = (week) => {
16 | if (!week) { return '' }
17 | return R.map(translateWeekName(week), week.types).join(' / ')
18 | }
19 |
20 | /**
21 | * Displays properties of the specified semester week (period type, teching
22 | * week number and parity).
23 | */
24 | const SemesterWeek = ({ semester, viewDate }) => {
25 | const week = findWeekByDate(semester.weeks || [], viewDate)
26 |
27 | return (
28 |
29 | {displayName(week)}
30 |
31 | )
32 | }
33 |
34 | export default SemesterWeek
35 |
--------------------------------------------------------------------------------
/src/components/SidebarIcal.jsx:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react'
2 | import { findDOMNode } from 'react-dom'
3 | import CP from 'counterpart'
4 |
5 | const SidebarIcal = React.createClass({
6 |
7 | propTypes: {
8 | username: PropTypes.string,
9 | token: PropTypes.string,
10 | },
11 |
12 | onInputClick () {
13 | findDOMNode(this.refs.urlInput).select()
14 | },
15 |
16 | getIcalUrl () {
17 | const {username, token} = this.props
18 | if (!username || !token) {
19 | return ''
20 | }
21 | return `https://sirius.fit.cvut.cz/api/v1/people/${username}/events.ical?access_token=${token}`
22 | },
23 |
24 | render () {
25 | const icalUrl = this.getIcalUrl()
26 | return (
27 |
52 | )
53 | },
54 | })
55 |
56 | export default SidebarIcal
57 |
--------------------------------------------------------------------------------
/src/components/Spinner.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Ajax spinner element
3 | * Used Spinkit @ tobiasahlin.com/spinkit, thanks
4 | */
5 |
6 | import React, { PropTypes } from 'react'
7 |
8 | const propTypes = {
9 | show: PropTypes.bool,
10 | }
11 |
12 | class Spinner extends React.Component {
13 |
14 | render () {
15 |
16 | return (
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | )
25 | }
26 | }
27 |
28 | Spinner.propTypes = propTypes
29 |
30 | export default Spinner
31 |
--------------------------------------------------------------------------------
/src/components/Toggleable.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Base class (component) for all toggleable components. Used primarily for functions dialogs
3 | */
4 |
5 | import React from 'react'
6 |
7 | class Toggleable extends React.Component {
8 |
9 | /**
10 | * Toggles this component
11 | */
12 | toggle () {
13 |
14 | if (!this.refs.rootEl.classList.contains('hide')) {
15 | this.refs.rootEl.classList.add('hide')
16 | } else {
17 | this.refs.rootEl.classList.remove('hide')
18 | }
19 | }
20 |
21 | /**
22 | * Shows this component
23 | */
24 | show () {
25 | this.refs.rootEl.classList.remove('hide')
26 | }
27 |
28 | hide () {
29 |
30 | if (!this.refs.rootEl.classList.contains('hide')) {
31 | this.refs.rootEl.classList.add('hide')
32 | }
33 | }
34 | }
35 |
36 | export default Toggleable
37 |
--------------------------------------------------------------------------------
/src/components/UserMenu.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import LogoutButton from './LogoutButton'
4 |
5 | export default function UserMenu ({ userName, onLogout }) {
6 | return (
7 |
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/WeekNav.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Week navigator components consists of two buttons, one for navigation to the left and second for navigation to the
3 | * right.
4 | */
5 |
6 | import React, { PropTypes } from 'react'
7 | import moment from 'moment'
8 | import CP from 'counterpart'
9 | import { weekRange, workWeekRange } from '../date'
10 | import { isScreenMediumAndUp } from '../screen'
11 |
12 | const propTypes = {
13 | onCalClick: PropTypes.func,
14 | onPrevClick: PropTypes.func,
15 | onNextClick: PropTypes.func,
16 | viewDate: PropTypes.instanceOf(Date),
17 | }
18 |
19 | class WeekNav extends React.Component {
20 | /**
21 | * Handler for events, when the calendar button is clicked ( bubbles to parent )
22 | * @param e event
23 | */
24 | handleCalClick (e) {
25 | this.props.onCalClick(e)
26 | }
27 | /**
28 | * Handler for events, when the previous button is clicked ( bubbles to parent )
29 | * @param e event
30 | */
31 | handlePrevClick (e) {
32 | this.props.onPrevClick(e)
33 | }
34 |
35 | /**
36 | * Handler for events, when the next button is clicked ( bubbles to parent )
37 | * @param e
38 | */
39 | handleNextClick (e) {
40 | this.props.onNextClick(e)
41 | }
42 |
43 | viewDate () {
44 | const rangeFun = this.props.days7 ? weekRange : workWeekRange
45 |
46 | // FIXME: remove moment dependency
47 | const [weekStart, weekEnd] = rangeFun(this.props.viewDate).map(d => moment(d))
48 | const currDayM = moment(this.props.viewDate)
49 |
50 | const rangeIsWeek = isScreenMediumAndUp(this.props.screenSize)
51 |
52 | if (rangeIsWeek) {
53 | if (moment.locale() === 'cs') {
54 | // u2013 : – \u2009 :
55 | return `${weekStart.format('D.\u2009M. ')} \u2013 ${weekEnd.format('D.\u2009M.\u2009YYYY')}`
56 | } else {
57 | return `${weekStart.format('YYYY-MM-DD')} \u2013 ${weekEnd.format('YYYY-MM-DD')}`
58 | }
59 | } else {
60 | return `${currDayM.format('dddd D.\u2009MMMM')}`
61 | }
62 | }
63 |
64 | render () {
65 |
66 | return (
67 |
68 |
74 |
75 |
76 |
82 | {this.viewDate()}
83 |
84 |
90 |
91 |
92 |
93 | )
94 | }
95 | }
96 |
97 | WeekNav.propTypes = propTypes
98 |
99 | export default WeekNav
100 |
--------------------------------------------------------------------------------
/src/config.js:
--------------------------------------------------------------------------------
1 | export const TZ = 'Europe/Prague'
2 |
3 | export const SIRIUS_PROXY_PATH = global.SIRIUS_PROXY_PATH || process.env.SIRIUS_PROXY_PATH
4 |
5 | export const OAUTH_PROXY_PATH = global.OAUTH_PROXY_PATH || '/_oauth/'
6 |
7 | export const FACULTY_ID = 18000 // FIT CTU
8 |
9 | export const SENTRY_DSN = global.SENTRY_DSN || process.env.SENTRY_DSN
10 |
--------------------------------------------------------------------------------
/src/constants/actionTypes.js:
--------------------------------------------------------------------------------
1 |
2 | export const SETTINGS_CHANGE = 'SETTINGS_CHANGE'
3 | export const DISPLAY_FILTERS_CHANGE = 'DISPLAY_FILTERS_CHANGE'
4 | export const EVENTS_LOAD_STARTED = 'EVENTS_LOAD_STARTED'
5 | export const EVENTS_LOAD_COMPLETED = 'EVENTS_LOAD_COMPLETED'
6 | export const EVENTS_LOAD_FAILED = 'EVENTS_LOAD_FAILED'
7 | export const SIDEBAR_DISPLAY = 'SIDEBAR_DISPLAY'
8 | export const EVENT_DISPLAY = 'EVENT_DISPLAY'
9 | export const RESET_DISPLAY = 'RESET_DISPLAY'
10 | export const SEARCH_REQUEST = 'SEARCH_REQUEST'
11 | export const SEARCH_RESPONSE = 'SEARCH_RESPONSE'
12 | export const SEARCH_CLEAR = 'SEARCH_CLEAR'
13 | export const SEMESTER_LOAD_COMPLETED = 'SEMESTER_LOAD_COMPLETED'
14 | export const CLIENT_CHANGE = 'CLIENT_CHANGE'
15 | export const DATA_ERROR_HIDE = 'DATA_ERROR_HIDE'
16 |
17 | export const USER_LOAD_STARTED = 'USER_LOAD_STARTED'
18 | export const USER_LOAD_COMPLETED = 'USER_LOAD_COMPLETED'
19 |
--------------------------------------------------------------------------------
/src/constants/api.js:
--------------------------------------------------------------------------------
1 |
2 | export const DEFAULT_CALENDAR_TYPE = 'people'
3 | export const DEFAULT_CALENDAR_ID = 'me'
4 |
--------------------------------------------------------------------------------
/src/constants/events.js:
--------------------------------------------------------------------------------
1 | // Maximum dimensions of expanded Event component in pixels,
2 | // used for calculating screen overflow.
3 | export const EVENT_MAX_WIDTH = 300
4 | export const EVENT_MAX_HEIGHT = 400
5 | export const EVENT_HEAD_HEIGHT = 80
6 |
--------------------------------------------------------------------------------
/src/constants/index.js:
--------------------------------------------------------------------------------
1 | export * from './actionTypes'
2 | export * from './api'
3 |
--------------------------------------------------------------------------------
/src/constants/propTypes.js:
--------------------------------------------------------------------------------
1 | import { PropTypes } from 'react'
2 | import Moment from 'moment'
3 |
4 | export const event = PropTypes.shape({
5 | id: PropTypes.oneOfType(PropTypes.number, PropTypes.string),
6 | name: PropTypes.string,
7 | course: PropTypes.string,
8 | startsAt: PropTypes.string, // FIXME: regexp for ISO string
9 | endsAt: PropTypes.string,
10 | sequenceNumber: PropTypes.number,
11 | type: PropTypes.string,
12 | room: PropTypes.string,
13 | // flag: PropTypes.string,
14 | notification: PropTypes.bool,
15 | cancelled: PropTypes.bool,
16 | replacement: PropTypes.bool,
17 | teachers: PropTypes.arrayOf(PropTypes.string),
18 | details: PropTypes.shape({
19 | description: PropTypes.string,
20 | students: PropTypes.array, // FIXME: shape for student
21 | capacity: PropTypes.number,
22 | parallel: PropTypes.string,
23 | appliedExceptions: PropTypes.array, // FIXME: shape for exceptions
24 | }),
25 | })
26 |
27 | export const options = Object.freeze({
28 | callbacks: PropTypes.shape({
29 | search: PropTypes.func,
30 | data: PropTypes.func.isRequired,
31 | semesterData: PropTypes.func.isRequired,
32 | dateChange: PropTypes.func.isRequired,
33 | }),
34 | locale: PropTypes.oneOf(['cs', 'en']),
35 | layout: PropTypes.oneOf(['horizontal', 'vertical']),
36 | colors: PropTypes.bool,
37 | days7: PropTypes.bool,
38 | facultygrid: PropTypes.bool,
39 | })
40 |
41 | export const grid = PropTypes.shape({
42 | starts: PropTypes.number,
43 | ends: PropTypes.number,
44 | lessonDuration: PropTypes.number,
45 | hoursStartsAt1: PropTypes.bool,
46 | facultyHours: PropTypes.number,
47 | facultyGrid: PropTypes.bool,
48 | })
49 |
50 | export const semester = PropTypes.shape({
51 | id: PropTypes.string,
52 | semester: PropTypes.string,
53 | faculty: PropTypes.number,
54 | startsOn: moment,
55 | endsOn: moment,
56 | hourDuration: PropTypes.number,
57 | breakDuration: PropTypes.number,
58 | dayStartsAtHour: PropTypes.number, // FIXME: this should be probably replaced with hourStarts array
59 | dayEndsAtHour: PropTypes.number, // FIXME: this should be probably replaced with hourStarts array
60 | periods: PropTypes.arrayOf(period),
61 | })
62 |
63 | export const period = PropTypes.shape({
64 | type: PropTypes.oneOf(['exams', 'holiday', 'teaching']),
65 | startsOn: moment,
66 | endsOn: moment,
67 | firstWeekParity: PropTypes.oneOf(['odd', 'even']),
68 | firstDayOverride: PropTypes.string,
69 | })
70 |
71 | const periodType = PropTypes.oneOf(['exams', 'holiday', 'teaching'])
72 |
73 | export const semesterWeek = PropTypes.shape({
74 | weekstamp: PropTypes.number,
75 | periods: PropTypes.arrayOf(period),
76 | types: PropTypes.arrayOf(periodType),
77 | parity: PropTypes.oneOf(['even', 'odd']), // optional
78 | teachingWeek: PropTypes.number, // optional
79 | })
80 |
81 | export const moment = (props, propName, componentName) => {
82 | const prop = props[propName]
83 | if (!Moment.isMoment(prop)) {
84 | return new Error(`Expected ${propName} to be an instance of Moment. Got ${typeof prop}.`)
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/src/constants/screenSizes.js:
--------------------------------------------------------------------------------
1 | export const SMALL_SCREEN = 1
2 | export const MEDIUM_SCREEN = 2
3 | export const LARGE_SCREEN = 3
4 |
5 | export const SMALL_SCREEN_BREAKPOINT = 480
6 | export const MEDIUM_SCREEN_BREAKPOINT = 800
7 |
--------------------------------------------------------------------------------
/src/dataManipulation.js:
--------------------------------------------------------------------------------
1 |
2 | // FIXME: this should be handled by data provider
3 | export function invertLinkNames (linksNames) {
4 | const ret = {
5 | cs: { courses: {}, teachers: {}, exceptions: {} },
6 | en: { courses: {}, teachers: {}, exceptions: {} },
7 | }
8 |
9 | function addNewLinkName (key, name, type, locale) {
10 | ret[locale][type][key] = name
11 | }
12 |
13 | if ('teachers' in linksNames) {
14 | for (const tlinkname of linksNames.teachers) {
15 | addNewLinkName(tlinkname.id, tlinkname.name.cs, 'teachers', 'cs')
16 | addNewLinkName(tlinkname.id, tlinkname.name.en, 'teachers', 'en')
17 | }
18 | }
19 |
20 | // Save courses link names
21 | if ('courses' in linksNames) {
22 | for (const clinkname of linksNames.courses) {
23 | addNewLinkName(clinkname.id, clinkname.name.cs, 'courses', 'cs')
24 | addNewLinkName(clinkname.id, clinkname.name.en, 'courses', 'en')
25 | }
26 | }
27 |
28 | // Save exceptions link names
29 | if ('exceptions' in linksNames) {
30 | for (const clinkname of linksNames.exceptions) {
31 | addNewLinkName(clinkname.id, clinkname.name, 'exceptions', 'cs')
32 | addNewLinkName(clinkname.id, clinkname.name, 'exceptions', 'en')
33 | }
34 | }
35 |
36 | return ret
37 | }
38 |
--------------------------------------------------------------------------------
/src/date.js:
--------------------------------------------------------------------------------
1 | import moment from 'moment-timezone'
2 | import frozenMoment from 'frozen-moment'
3 | import R from 'ramda'
4 |
5 | import { TZ } from './config'
6 |
7 | export const now = () => Object.freeze(new Date())
8 |
9 | export function strToDate (date) {
10 | return new Date(date)
11 | }
12 |
13 | export function isoDate (date) {
14 | return moment.tz(date, TZ).format('YYYY-MM-DD')
15 | }
16 |
17 | export function tzMoment (date) {
18 | return moment.tz(date, TZ)
19 | }
20 |
21 | function momentWeekRange (date) {
22 | const m = moment.tz(date, TZ)
23 |
24 | const weekStart = m.clone().startOf('isoWeek')
25 | const weekEnd = m.clone().endOf('isoWeek')
26 |
27 | return [weekStart, weekEnd]
28 | }
29 |
30 | export function weekRange (date) {
31 | return momentWeekRange(date).map(m => m.toDate())
32 | }
33 |
34 | export function workWeekRange (date) {
35 | const [weekStart, weekEnd] = momentWeekRange(date)
36 |
37 | return [weekStart, weekEnd.subtract(2, 'days')].map(m => m.toDate())
38 | }
39 |
40 | export function isoWeekRange (date) {
41 | return weekRange(date).map(d => isoDate(d))
42 | }
43 |
44 | export const shiftDate = R.curry((baseDate, kind, offset) => {
45 | return moment.tz(baseDate, TZ).add(offset, kind).toDate()
46 | })
47 |
48 | export function weekdayNum (date) {
49 | // getDay returns 0 for Sunday, 1 Monday etc.
50 | // thus we need to shift by 6 and mod by 7
51 | return (date.getDay() + 6) % 7
52 | }
53 |
54 | export function compareDate (a, b) {
55 | const mA = moment(a)
56 | const mB = moment(b)
57 | if (mA.isBefore(mB, 'day')) {
58 | return -1
59 | }
60 | if (mA.isAfter(mB, 'day')) {
61 | return 1
62 | }
63 |
64 | return 0
65 | }
66 |
67 | export function withinDates (min, max, date) {
68 | const mDate = moment(date)
69 | return mDate.isBetween(min, max, 'day') || mDate.isSame(min, 'day') || mDate.isSame(max, 'day')
70 | }
71 |
72 | export function setDateToZeroTime (date) {
73 | const newDate = new Date(date)
74 | newDate.setHours(0)
75 | newDate.setMinutes(0)
76 | newDate.setSeconds(0)
77 | return newDate
78 | }
79 |
80 | export function weekStartDate (date) {
81 | return moment(date).startOf('isoWeek').toDate()
82 | }
83 |
84 | export const fmoment = (date) => frozenMoment(date).freeze()
85 |
--------------------------------------------------------------------------------
/src/error-unauthorized.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Error
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
Problém s autorizací
15 |
16 | Pravděpodobně jste aplikaci fittable nepovolili přístup k datům ze Siria nebo došlo k chybě při přihlašování.
17 | Zkuste se přihlásit znovu .
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/src/error.html:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cvut/fittable/209389f08b01749cb81bf0d3215b7480354b9328/src/error.html
--------------------------------------------------------------------------------
/src/globalApi.js:
--------------------------------------------------------------------------------
1 | import { isoDate } from './date'
2 |
3 | function register (global, {changeDate, getCurrentDate}) {
4 | global.fittable = {
5 | changeDate,
6 | getCurrentDate,
7 | getCurrentDateStr: () => isoDate(getCurrentDate()),
8 | }
9 |
10 | return global.fittable
11 | }
12 |
13 | function unregister (global) {
14 | if (global.fittable) {
15 | delete global.fittable
16 | return true
17 | }
18 | return false
19 | }
20 |
21 | export default {
22 | register,
23 | unregister,
24 | }
25 |
--------------------------------------------------------------------------------
/src/images/landing-page-devices.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cvut/fittable/209389f08b01749cb81bf0d3215b7480354b9328/src/images/landing-page-devices.png
--------------------------------------------------------------------------------
/src/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cvut/fittable/209389f08b01749cb81bf0d3215b7480354b9328/src/images/logo.png
--------------------------------------------------------------------------------
/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | fittable beta | FIT ČVUT
9 |
10 |
11 |
12 |
15 |
16 |
17 |
18 |
19 | Ve vašem prohlížeči je zřejmě zakázné používání JavaScriptu. Je nám líto, ale bez něj tato aplikace fungovat nebude.
20 |
21 |
22 | It looks like you have JavaScript disabled in your browser. We are sorry but this application won’t work without it. Please, see the instructions on how to enable JavaScript .
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/src/landing.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | fittable
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | Pro přístup k vašemu osobnímu rozvrhu se musíte nejprve přihlásit svým školním účtem.
20 |
21 |
22 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
Poznejte nový timetable.
35 |
36 | Představujeme vám novou aplikaci pro rozvrh. Vytvořili jsme mnohem modernější,
37 | svižnější a především přizpůsobitelnější aplikaci. Nyní je to skutečně váš osobní rozvrh.
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
49 |
50 |
51 |
52 |
--------------------------------------------------------------------------------
/src/locale.js:
--------------------------------------------------------------------------------
1 | import CP from 'counterpart'
2 |
3 | /**
4 | * Translates, pluralizes and interpolates the given key using the specified
5 | * locale, scope, and fallback, as well as interpolation values.
6 | *
7 | * If the key is null or undefined and the options contains `fallback`, then
8 | * the fallback is returned.
9 | *
10 | * @sig (String, {*}) -> String
11 | */
12 | export function translate (key, options = {}) {
13 | if (!key && 'fallback' in options) {
14 | return options.fallback
15 | }
16 | return CP.translate(key, options)
17 | }
18 |
--------------------------------------------------------------------------------
/src/reducers/clientReducer.js:
--------------------------------------------------------------------------------
1 | import { CLIENT_CHANGE } from '../constants/actionTypes'
2 | import { SMALL_SCREEN } from '../constants/screenSizes'
3 |
4 | const initialState = {
5 | screenSize: SMALL_SCREEN,
6 | }
7 |
8 | export default function client (state = initialState, action) {
9 | switch (action.type) {
10 | case CLIENT_CHANGE:
11 | return {
12 | ...action.payload,
13 | }
14 | default:
15 | return state
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/reducers/dataReducer.js:
--------------------------------------------------------------------------------
1 | import {
2 | EVENTS_LOAD_STARTED, EVENTS_LOAD_COMPLETED, EVENTS_LOAD_FAILED, DATA_ERROR_HIDE,
3 | } from '../constants/actionTypes'
4 |
5 | const initialState = {
6 | waiting: true,
7 | linkNames: {
8 | cs: { courses: {}, teachers: {}, exceptions: {} },
9 | en: { courses: {}, teachers: {}, exceptions: {} },
10 | },
11 | events: [],
12 | errorVisible: false,
13 | error: {
14 | type: null,
15 | message: null,
16 | },
17 | }
18 |
19 | export default function data (state = initialState, action) {
20 | switch (action.type) {
21 | case EVENTS_LOAD_STARTED:
22 | return {
23 | ...state,
24 | waiting: true,
25 | }
26 | case EVENTS_LOAD_COMPLETED:
27 | const { events, linkNames } = action.payload
28 | return {
29 | ...state,
30 | waiting: false,
31 | events,
32 | linkNames,
33 | errorVisible: false,
34 | }
35 | case EVENTS_LOAD_FAILED:
36 | const type = action.payload.type
37 | return {
38 | ...state,
39 | waiting: false,
40 | errorVisible: true,
41 | error: {
42 | type,
43 | message: action.payload.toString(),
44 | },
45 | }
46 | case DATA_ERROR_HIDE:
47 | return {
48 | ...state,
49 | errorVisible: false,
50 | }
51 | default:
52 | return state
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux'
2 | import { merge } from 'ramda'
3 |
4 | import { routerStateReducer } from 'redux-router'
5 |
6 | import { DISPLAY_FILTERS_CHANGE } from '../constants/actionTypes'
7 |
8 | import data from './dataReducer'
9 | import ui from './uiReducer'
10 | import search from './searchReducer'
11 | import semester from './semesterReducer'
12 | import client from './clientReducer'
13 | import user from './userReducer'
14 | import settings from './settingsReducer'
15 |
16 | const initialDisplayFilters = {
17 | /*eslint-disable */
18 | laboratory: true,
19 | tutorial: true,
20 | lecture: true,
21 | exam: true,
22 | assessment: true,
23 | course_event: true,
24 | teacher_timetable_slot: true,
25 | other: true,
26 | /*eslint-enable */
27 | }
28 |
29 | function displayFilters (state = initialDisplayFilters, action) {
30 | switch (action.type) {
31 | case DISPLAY_FILTERS_CHANGE:
32 | return merge(state, action.displayFilters)
33 | default:
34 | return state
35 | }
36 | }
37 |
38 | const rootReducer = combineReducers({
39 | router: routerStateReducer,
40 | settings,
41 | displayFilters,
42 | data,
43 | ui,
44 | search,
45 | semester,
46 | client,
47 | user,
48 | })
49 |
50 | export default rootReducer
51 |
--------------------------------------------------------------------------------
/src/reducers/searchReducer.js:
--------------------------------------------------------------------------------
1 | import { SEARCH_REQUEST, SEARCH_RESPONSE, SEARCH_CLEAR } from '../constants/actionTypes'
2 |
3 | const initialState = {
4 | waiting: false,
5 | query: '',
6 | results: [],
7 | }
8 |
9 | export default function search (state = initialState, action) {
10 | switch (action.type) {
11 | case SEARCH_REQUEST:
12 | return {
13 | waiting: true,
14 | query: action.payload.query,
15 | results: [],
16 | }
17 | case SEARCH_RESPONSE:
18 | return {
19 | ...state,
20 | waiting: false,
21 | results: action.payload.results,
22 | }
23 | case SEARCH_CLEAR:
24 | return {
25 | ...state,
26 | results: [],
27 | }
28 | default:
29 | return state
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/reducers/semesterReducer.js:
--------------------------------------------------------------------------------
1 | import { SEMESTER_LOAD_COMPLETED } from '../constants/actionTypes'
2 |
3 | const initialState = {
4 | season: 'winter', // XXX: this is a placeholder
5 | grid: {
6 | // Fallback data for FIT
7 | starts: 7.5,
8 | ends: 21.5,
9 | lessonDuration: 0.875,
10 | },
11 | periods: [],
12 | valid: false,
13 | /*
14 | startsOn,
15 | endsOn,
16 | periods
17 | */
18 | }
19 |
20 | export default function semester (state = initialState, action) {
21 | switch (action.type) {
22 | case SEMESTER_LOAD_COMPLETED:
23 | return {
24 | ...state,
25 | ...action.payload,
26 | }
27 | default:
28 | return state
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/reducers/settingsReducer.js:
--------------------------------------------------------------------------------
1 | import { SETTINGS_CHANGE } from '../constants/actionTypes'
2 | import { merge } from 'ramda'
3 |
4 | export const initialState = {
5 | locale: 'cs',
6 | layout: 'vertical',
7 | eventsColors: false,
8 | fullWeek: false,
9 | facultyGrid: false,
10 | }
11 |
12 | export default function settings (state = initialState, action) {
13 | switch (action.type) {
14 | case SETTINGS_CHANGE:
15 | return merge(state, action.settings)
16 | default:
17 | return state
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/reducers/uiReducer.js:
--------------------------------------------------------------------------------
1 | import { SIDEBAR_DISPLAY, EVENT_DISPLAY } from '../constants/actionTypes'
2 |
3 | const initialState = {
4 | sidebar: null,
5 | eventId: null,
6 | }
7 |
8 | export default function ui (state = initialState, action) {
9 | switch (action.type) {
10 | case SIDEBAR_DISPLAY:
11 | // FIXME: consider handling this in uiAction with thunk
12 | let {sidebar} = action.payload
13 | if (sidebar === state.sidebar) {
14 | sidebar = null
15 | }
16 | return {
17 | ...state,
18 | sidebar: sidebar,
19 | }
20 | case EVENT_DISPLAY:
21 | const {eventId} = action.payload
22 | return {
23 | ...state,
24 | eventId,
25 | }
26 | default:
27 | return state
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/reducers/userReducer.js:
--------------------------------------------------------------------------------
1 | import { USER_LOAD_STARTED, USER_LOAD_COMPLETED } from '../constants/actionTypes'
2 |
3 | const initialState = {
4 | isFetching: false,
5 | publicAccessToken: null,
6 | id: null,
7 | name: null,
8 | }
9 |
10 | export default function userReducer (state = initialState, action) {
11 | switch (action.type) {
12 | case USER_LOAD_STARTED:
13 | return {
14 | ...state,
15 | isFetching: true,
16 | }
17 | case USER_LOAD_COMPLETED:
18 | return {
19 | ...state,
20 | ...action.payload,
21 | isFetching: false,
22 | }
23 | default:
24 | return state
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/routes.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Route } from 'react-router'
3 |
4 | const routes = (
5 |
6 |
7 |
8 | )
9 |
10 | export default routes
11 |
--------------------------------------------------------------------------------
/src/screen.js:
--------------------------------------------------------------------------------
1 | import { SMALL_SCREEN, MEDIUM_SCREEN, LARGE_SCREEN } from './constants/screenSizes'
2 |
3 | export function isScreenSmall (screenSize) {
4 | return screenSize === SMALL_SCREEN
5 | }
6 |
7 | export function isScreenMedium (screenSize) {
8 | return screenSize === MEDIUM_SCREEN
9 | }
10 |
11 | export function isScreenMediumAndUp (screenSize) {
12 | return screenSize >= MEDIUM_SCREEN
13 | }
14 |
15 | export function isScreenLarge (screenSize) {
16 | return screenSize === LARGE_SCREEN
17 | }
18 |
19 | /**
20 | * Maps actual screen size to classes specified in the second parameter
21 | * The second parameter should be array with classnames sorted from small to large,
22 | * or associative array with keys 'small', 'medium' and 'large'.
23 | * @param {number} screenSize
24 | * @param {*} classes
25 | * @returns {string} Class name by screen size
26 | */
27 | export function classByScreenSize (screenSize, classes) {
28 | if (isScreenSmall(screenSize)) {
29 | return classes['small'] || classes[0]
30 | } else if (isScreenMedium(screenSize)) {
31 | return classes['medium'] || classes[1]
32 | } else {
33 | return classes['large'] || classes[2]
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/selectors/routerSelector.js:
--------------------------------------------------------------------------------
1 | import { pipe, path, prop, propOr } from 'ramda'
2 |
3 | import { DEFAULT_CALENDAR_ID, DEFAULT_CALENDAR_TYPE } from '../constants'
4 | import { now, isoDate } from '../date'
5 |
6 | const currentDate = () => isoDate(now())
7 |
8 | const routerParams = path(['router', 'params'])
9 | const querySelector = path(['router', 'location', 'query'])
10 |
11 | export const calendarId = pipe(routerParams, propOr(DEFAULT_CALENDAR_ID, 'calendarId'))
12 | export const calendarType = pipe(routerParams, propOr(DEFAULT_CALENDAR_TYPE, 'calendarType'))
13 |
14 | // Requires currentDate() to be reinvoked
15 | export const viewDate = (state) => (
16 | prop('date', querySelector(state) || {}) || currentDate()
17 | )
18 |
19 | export const calendar = (state) => ({
20 | id: calendarId(state),
21 | type: calendarType(state),
22 | date: viewDate(state),
23 | })
24 |
--------------------------------------------------------------------------------
/src/semester.js:
--------------------------------------------------------------------------------
1 | import R from 'ramda'
2 | import { withinDates } from './date'
3 | import { semesterPeriodsToWeeks } from './semesterWeeks'
4 |
5 | const semesterInterval = R.props(['startsOn', 'endsOn'])
6 | export const dateInSemester = (semester, date) => withinDates(...semesterInterval(semester), date)
7 |
8 | export function findSemester (semesters, date) {
9 | // XXX: must be wrapped since we have a different order
10 | const predicate = (sem) => dateInSemester(sem, date)
11 |
12 | return R.find(predicate, semesters)
13 | }
14 |
15 | export function semesterSeason (semesterId) {
16 | const lastChar = semesterId.slice(-1)
17 | if (lastChar === '1') {
18 | return 'winter'
19 | }
20 | return 'summer'
21 | }
22 |
23 | export function semesterYears (semesterId) {
24 | const yy = semesterId.slice(1, 3)
25 | const year = parseInt(`20${yy}`, 10) // TODO: make this Y2100 compliant
26 | return [year, year + 1]
27 | }
28 |
29 | export function convertRawSemester (semester) {
30 | const { dayStartsAtHour, dayEndsAtHour, hourDuration, breakDuration } = semester
31 | const hDur = hourDuration / 60
32 | const bDur = breakDuration / 60
33 | const lessonDuration = ((hDur * 2) + bDur) / 2
34 | return {
35 | id: semester.id,
36 | startsOn: semester.startsOn,
37 | endsOn: semester.endsOn,
38 | season: semesterSeason(semester.semester),
39 | years: semesterYears(semester.semester),
40 | grid: {
41 | starts: dayStartsAtHour,
42 | ends: dayEndsAtHour,
43 | lessonDuration,
44 | },
45 | weeks: semesterPeriodsToWeeks(semester.periods),
46 | valid: true,
47 | }
48 | }
49 |
50 | export function semesterName (translate, semester) {
51 | if (!semester || !semester.valid || !semester.season || !semester.years) {
52 | return null
53 | }
54 | const translateKey = `${semester.season}_sem`
55 | const academicYear = `${semester.years[0]}/${semester.years[1] % 100}`
56 |
57 | return translate(translateKey, { year: academicYear })
58 | }
59 |
--------------------------------------------------------------------------------
/src/semesterWeeks.js:
--------------------------------------------------------------------------------
1 | import R from 'ramda'
2 | import { fmoment } from './date'
3 | import { forEachWithIndex, reduceBy } from './utils'
4 |
5 | // The reference point for weeks since the epoch.
6 | const referenceDate = fmoment(0).startOf('isoWeek').freeze()
7 |
8 | /**
9 | * Returns number of ISO weeks since the start of the Unix epoch. Note that the
10 | * week 1 starts on 1969-12-29.
11 | *
12 | * @see http://www.epochconverter.com/date-and-time/weeknumbers-by-year.php?year=1970
13 | * @sig Moment -> Number
14 | */
15 | const weeksSinceEpoch = (date) => referenceDate.diff(date, 'weeks') * -1
16 |
17 | /**
18 | * Returns an array of Weekstamps from start to end of the given
19 | * Semester Period.
20 | *
21 | * @sig Period -> [Number]
22 | */
23 | const periodWeeksRange = ({ startsOn, endsOn }) => {
24 | return R.range(weeksSinceEpoch(startsOn), weeksSinceEpoch(endsOn) + 1)
25 | }
26 |
27 | // @sig Period -> Boolean
28 | const isRegularPeriod = (period) => !period.irregular
29 |
30 | // @sig Week -> Boolean
31 | const isTeachingWeek = (week) => week.types.includes('teaching')
32 |
33 | // @sig Number -> String
34 | const parityName = (num) => num % 2 ? 'odd' : 'even'
35 |
36 | // @sig String -> Number | undefined
37 | const parityToNum = (str) => {
38 | switch (str) {
39 | case 'even': return 0
40 | case 'odd': return 1
41 | default: return undefined
42 | }
43 | }
44 |
45 | /**
46 | * Computes a parity of the given week within a regular period with defined
47 | * firstWeekParity (i.e. teaching period). If there's no Period with the
48 | * firstWeekParity property, the function returns undefined.
49 | *
50 | * The calculation is based on difference between the period's start Weekstamp
51 | * and the given date's Weekstamp, shifted by the firstWeekParity:
52 | *
53 | * (weekstamp(date) - weekstamp(startsOn) + firstWeekParity) mod 2
54 | *
55 | * @sig ([Period], Number) -> String | undefined
56 | */
57 | const weekParity = (periods, weekstamp) => {
58 | const period = periods.find(R.both(
59 | isRegularPeriod,
60 | R.prop('firstWeekParity')
61 | ))
62 |
63 | if (period) {
64 | const weeksSinceStart = weekstamp - weeksSinceEpoch(period.startsOn)
65 | const parity = (weeksSinceStart + parityToNum(period.firstWeekParity)) % 2
66 |
67 | return parityName(parity)
68 | }
69 | }
70 |
71 | // @sig [Period] -> [String]
72 | const regularPeriodsTypes = R.pipe(
73 | R.filter(isRegularPeriod),
74 | R.map(p => p.type),
75 | R.uniq
76 | )
77 |
78 | /**
79 | * Returns a new Semester Week object with the given Semester Periods that
80 | * belongs to the calendar week specified by the weekstamp.
81 | *
82 | * @sig (Number, [Period]) -> SemesterWeek
83 | */
84 | const createWeek = (weekstamp, periods) => ({
85 | weekstamp,
86 | periods,
87 | types: regularPeriodsTypes(periods),
88 | parity: weekParity(periods, weekstamp),
89 | teachingWeek: undefined,
90 | })
91 |
92 | /**
93 | * Groups Semester Periods into calendar weeks numbered by a Weekstamp (number
94 | * of weeks since the epoch). Each Semester Period can be present in multiple
95 | * calendar weeks (it's a many-to-many mapping).
96 | *
97 | * @sig [Period] -> {String: [Period]}
98 | */
99 | const periodsByWeeks = R.pipe(
100 | R.sortBy(p => p.startsOn),
101 | R.chain(p => R.xprod(periodWeeksRange(p), [p])),
102 | reduceBy(R.head, (acc, pair) => acc.concat(pair[1]), [])
103 | )
104 |
105 | /**
106 | * Transforms the Periods of a single Semester into the Semester Weeks indexed
107 | * by a Weekstamp (number of weeks since the epoch).
108 | *
109 | * @sig [Period] -> SemesterWeeks
110 | */
111 | export const semesterPeriodsToWeeks = R.pipe(
112 | periodsByWeeks,
113 | R.mapObjIndexed((periods, key) => {
114 | return createWeek(key * 1, periods)
115 | }),
116 | R.tap(R.pipe(
117 | R.values,
118 | R.filter(isTeachingWeek),
119 | R.sortBy(o => o.weekstamp),
120 | forEachWithIndex((week, idx) => {
121 | week.teachingWeek = idx + 1
122 | })
123 | ))
124 | )
125 |
126 | /**
127 | * Finds Semester Week in the given Semester Weeks object for the
128 | * specified date.
129 | *
130 | * @sig SemesterWeeks -> Moment | Date -> SemesterWeek
131 | */
132 | export const findWeekByDate = R.curry((weeks, date) => {
133 | return weeks[weeksSinceEpoch(date)]
134 | })
135 |
--------------------------------------------------------------------------------
/src/signpost.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Rozvrhy FIT ČVUT
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
Zvolte si zobrazení rozvrhu
16 |
17 |
38 |
43 |
44 |
45 |
46 |
Choose a timetable application
47 |
48 |
69 |
74 |
75 |
76 |
127 |
128 |
129 |
--------------------------------------------------------------------------------
/src/store/index.js:
--------------------------------------------------------------------------------
1 | import { compose, createStore as createReduxStore, applyMiddleware } from 'redux'
2 | import thunk from 'redux-thunk'
3 | import { mergePersistedState } from 'redux-localstorage'
4 | import { reduxReactRouter } from 'redux-router'
5 | import { createHistory, useBasename } from 'history'
6 |
7 | import rootReducer from '../reducers'
8 | import routes from '../routes'
9 |
10 | function createStore (initialState) {
11 | const middlewares = [
12 | thunk,
13 | ]
14 |
15 | if (process.env.NODE_ENV !== 'production') {
16 | const createLogger = require('redux-logger')
17 | middlewares.push(createLogger())
18 | }
19 |
20 | let reducer = rootReducer
21 |
22 | // Use history with support
23 | // FIXME: Add support for memory history in Node env
24 | const history = useBasename(createHistory)()
25 |
26 | // Store enhancers
27 | let finalCreateStore = compose(
28 | applyMiddleware(...middlewares),
29 | reduxReactRouter({
30 | routes,
31 | history,
32 | })
33 | )(createReduxStore)
34 |
35 | // Persistence is enabled only conditionally
36 | if (global.localStorage) {
37 | const createPersistentStore = require('./persistence')
38 | finalCreateStore = createPersistentStore()(finalCreateStore)
39 | reducer = compose(
40 | mergePersistedState()
41 | )(reducer)
42 | }
43 |
44 | const store = finalCreateStore(reducer, initialState)
45 |
46 | if (module.hot) {
47 | const nextReducer = require('../reducers')
48 | module.hot.accept('../reducers', () => {
49 | store.replaceReducer(nextReducer)
50 | })
51 | }
52 |
53 | return store
54 | }
55 |
56 | export default createStore
57 |
--------------------------------------------------------------------------------
/src/store/persistence.js:
--------------------------------------------------------------------------------
1 | import { compose } from 'redux'
2 | import persistState from 'redux-localstorage'
3 | import adapter from 'redux-localstorage/lib/adapters/localStorage'
4 | import filter from 'redux-localstorage-filter'
5 |
6 | // Key under which state will be stored
7 | const STORAGE_KEY = 'fittable'
8 | // What parts of store should be persisted
9 | const STORAGE_FILTER = 'settings'
10 |
11 | const storage = compose(
12 | filter(STORAGE_FILTER)
13 | )(adapter(window.localStorage))
14 |
15 | const createPersistentStore = () => {
16 | return persistState(storage, STORAGE_KEY)
17 | }
18 |
19 | export default createPersistentStore
20 |
--------------------------------------------------------------------------------
/src/stylesheets/_base.scss:
--------------------------------------------------------------------------------
1 | @import url(https://fonts.googleapis.com/css?family=Roboto:400,300,400italic,700&subset=latin,latin-ext);
2 | @import url(https://maxcdn.bootstrapcdn.com/font-awesome/4.3.0/css/font-awesome.min.css);
3 |
4 | // Colors
5 | $darkgraytext-color: #444;
6 | $lightgraytext-color: #aaa;
7 | $lightgrayback-color: #f8f8f8;
8 | $grid-color: rgba( $secondary-color, 0.75 );
9 | $dark-secondary-text-color: #708898;
10 |
11 | // Font properties
12 | $xlarge-font-size: 18px;
13 | $large-font-size: 14px;
14 | // base font size set in _settings to 13px
15 | $small-font-size: 12px;
16 | $xsmall-font-size: 11px;
17 |
18 | // Anim speeds
19 | $default-anim-speed: .1s;
20 | $event-rollout-anim-speed: .2s;
21 | $events-move-anim-speed: .2s;
22 | $layoutchange-anim-speed: .5s;
23 |
24 | // Elements dimensions
25 | $header-elements-weight: 35px;
26 | $day-row-height: 105px;
27 | $day-row-padding: 12px;
28 | $vertical-layout-day-height: 960px;
29 | $nowindicator-thickness: 2px;
30 |
31 | $mobile-heading-height: 50px;
32 |
33 | $events-gutter: 8px;
34 |
35 | // Other
36 | $grid-line-width: 1.5px;
37 |
38 | #fittable {
39 | position: relative;
40 | }
41 |
42 | @media #{$medium-up} {
43 | .row.container {
44 | padding: 0 20px;
45 | }
46 | }
47 |
48 | @media #{$large-up} {
49 | .row.container {
50 | padding: 0 30px;
51 | }
52 |
53 | .row.container.compact {
54 | max-width: 1850px !important
55 | }
56 |
57 | body.with-line {
58 | border-top: 3px solid $primary-color;
59 | }
60 | }
61 |
62 | @media #{$xxlarge-up} {
63 | .row {
64 | max-width: 1500px;
65 | }
66 | }
67 |
68 | // WP history swipe hack ( disable swiping right and left handling )
69 | * {
70 | touch-action: manipulation;
71 | }
72 |
73 | // Highlight color
74 | ::selection, ::-moz-selection {
75 | background: $primary-color;
76 | color: white;
77 | }
78 |
79 | // iOS header
80 | @media #{$small-down} {
81 | body.ios-bg {
82 | background-color: $primary-color;
83 | }
84 |
85 | .fittable-container {
86 | background: white;
87 | }
88 | }
89 |
90 | @media #{$small-down} {
91 | body {
92 | font-size: 16px;
93 | }
94 | }
95 |
96 | // Hidden
97 | .u-hidden {
98 | display: none;
99 | }
100 |
--------------------------------------------------------------------------------
/src/stylesheets/_components.scss:
--------------------------------------------------------------------------------
1 | @import "components/error";
2 | @import "components/flags-control";
3 | @import "components/footer";
4 | @import "components/function-filter";
5 | @import "components/functions-bar";
6 | @import "components/functions-bar-responsive";
7 | @import "components/search";
8 | @import "components/function-settings";
9 | @import "components/functions-sidebar";
10 | @import "components/functions-sidebar-responsive";
11 | @import "components/grid";
12 | @import "components/header";
13 | @import "components/header-responsive";
14 | @import "components/landing-page";
15 | @import "components/spinner";
16 | @import "components/table-appearances";
17 | @import "components/table-animations";
18 | @import "components/table-events-colors";
19 | @import "components/table-events-detail";
20 | @import "components/table-events";
21 | @import "components/table";
22 | @import "components/table-responsive";
23 | @import "components/table-horizontal";
24 | @import "components/week-nav";
25 | @import "components/week-nav-responsive";
26 | @import "components/week-switcher";
27 | @import "components/SidebarIcal";
28 | @import "components/SemesterWeek";
29 | @import "components/LogoutButton";
30 |
31 | @import "components/signpost-page";
32 | @import "components/ukraine-ribbon";
33 |
--------------------------------------------------------------------------------
/src/stylesheets/_mixins.scss:
--------------------------------------------------------------------------------
1 | @mixin upcase {
2 | text-transform: uppercase;
3 | }
4 |
5 | @mixin bold-up-text {
6 | font-weight: 700;
7 | text-transform: uppercase;
8 | }
9 |
10 | @mixin color-hover-effect($color) {
11 | transition: background .2s;
12 |
13 | &:hover { // XXX: focus was intentionally omitted so buttons don't keep the color; perhaps we should do sth else
14 | color: lighten( $color, 10% );
15 | }
16 | }
17 |
18 | @mixin background-hover-effect($color) {
19 | transition: background .2s;
20 |
21 | &:hover {
22 | background-color: lighten( $color, 4% );
23 | }
24 | }
25 |
26 | @mixin disable-selection {
27 | -moz-user-select: -moz-none;
28 | -khtml-user-select: none;
29 | -webkit-user-select: none;
30 | -ms-user-select: none;
31 | user-select: none;
32 | }
33 |
34 | @mixin link {
35 | color: $anchor-font-color;
36 | line-height: inherit;
37 | text-decoration: $anchor-text-decoration;
38 |
39 | &:hover,
40 | &:focus {
41 | color: $anchor-font-color-hover;
42 | @if $anchor-text-decoration-hover != $anchor-text-decoration {
43 | text-decoration: $anchor-text-decoration-hover;
44 | }
45 | }
46 | }
47 |
48 | @mixin active-light {
49 | color: $primary-color;
50 | background-color: $body-bg;
51 | }
52 |
53 | @mixin active-dark {
54 | color: $body-bg;
55 | background-color: $primary-color;
56 | }
57 |
58 | @mixin active-light-hover {
59 | @include active-light;
60 | @include color-hover-effect( $primary-color );
61 | }
62 |
63 | @mixin active-dark-hover {
64 | @include active-dark;
65 | @include background-hover-effect( $primary-color );
66 | }
67 |
68 | %btn-light {
69 | @include active-light;
70 | }
71 |
72 | %btn-dark {
73 | @include active-dark-hover;
74 | }
75 |
76 | %btn-link {
77 | @include link;
78 | }
79 |
--------------------------------------------------------------------------------
/src/stylesheets/_reset.scss:
--------------------------------------------------------------------------------
1 | #fittable {
2 | button {
3 | // reset-box-model
4 | margin: 0;
5 | padding: 0;
6 | border: 0;
7 | // reset-font
8 | font: inherit;
9 | font-size: 100%;
10 | vertical-align: baseline;
11 | background: none;
12 | text-align: left;
13 | -webkit-appearance: none;
14 | // reset border-radius for mobile FF
15 | border-radius: 0;
16 |
17 | &:focus {
18 | outline: 0;
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/stylesheets/components/_LogoutButton.scss:
--------------------------------------------------------------------------------
1 | #fittable .LogoutButton {
2 | background: $primary-color;
3 | color: white;
4 | @include upcase();
5 | padding: .5rem 1rem;
6 | float: right;
7 |
8 | span {
9 | display: none;
10 | }
11 |
12 | @media #{$small-down} {
13 | background: none;
14 | font-size: 1.2rem;
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/stylesheets/components/_SemesterWeek.scss:
--------------------------------------------------------------------------------
1 | .SemesterWeek-text {
2 | float: left;
3 | position: relative;
4 | margin-left: 15px;
5 | color: $primary-color;
6 | line-height: 35px;
7 | text-transform: uppercase;
8 | }
9 |
--------------------------------------------------------------------------------
/src/stylesheets/components/_SidebarIcal.scss:
--------------------------------------------------------------------------------
1 |
2 | .SidebarIcal-input {
3 | width: 100%;
4 | border: 1px solid $dark-secondary-text-color;
5 | font-family: $body-font-family;
6 | font-size: 11px;
7 | padding: 4px 7px;
8 | }
9 |
--------------------------------------------------------------------------------
/src/stylesheets/components/_error.scss:
--------------------------------------------------------------------------------
1 | #fittable {
2 |
3 | .error-message {
4 | width: 100%;
5 | height: 100%;
6 | text-align: center;
7 | line-height: 1.85;
8 | position: absolute;
9 | z-index: 160;
10 | background: rgba(white, .85);
11 | display: flex;
12 | flex-direction: column;
13 | justify-content: center;
14 | top: 0;
15 |
16 | i.icon {
17 | margin: 40px 0 20px 0;
18 | font-size: 36px;
19 | color: $primary-color;
20 | }
21 |
22 | h2 {
23 | font-size: 25px;
24 | color: $primary-color;
25 | }
26 |
27 | p.please {
28 | margin: 40px auto 0 auto;
29 | padding: 25px;
30 | display: block;
31 | border-top: 1px solid #ccc;
32 | width: 60%;
33 | font-size: 12px;
34 | }
35 |
36 | button {
37 | display: block;
38 | background: $primary-color;
39 | color: white;
40 | padding: .5rem 2rem;
41 | margin: 2rem auto 0 auto;
42 |
43 | &:hover {
44 | @include active-dark-hover();
45 | }
46 | }
47 | }
48 |
49 | .error-message--alone {
50 | margin: 25%;
51 | width: auto;
52 | }
53 |
54 | }
55 |
--------------------------------------------------------------------------------
/src/stylesheets/components/_flags-control.scss:
--------------------------------------------------------------------------------
1 |
2 | #fittable {
3 |
4 | }
5 |
--------------------------------------------------------------------------------
/src/stylesheets/components/_footer.scss:
--------------------------------------------------------------------------------
1 | .footer {
2 | line-height: 16px;
3 | font-size: $base-font-size;
4 | color: $darkgraytext-color;
5 | padding-top: 4em;
6 |
7 | @media #{$small-down} {
8 | background: #363636;
9 | color: white;
10 | padding-top: 0;
11 | margin: 0 !important;
12 | }
13 |
14 | img {
15 | margin-right: 10px;
16 | vertical-align: middle;
17 | }
18 |
19 | i.fa {
20 | margin-right: 5px;
21 | }
22 |
23 | ul {
24 | display: inline;
25 | list-style: none;
26 | margin: 0 -8px;
27 | padding: 0;
28 | vertical-align: middle;
29 | line-height: 16px;
30 | }
31 |
32 | li {
33 | margin: 0 8px;
34 | padding: 0;
35 | display: inline-block;
36 | font-size: $base-font-size;
37 | }
38 |
39 | a {
40 | color: $darkgraytext-color;
41 | font-weight: 700;
42 | line-height: 16px;
43 |
44 | &:hover {
45 | text-decoration: underline;
46 | }
47 | }
48 |
49 | &-alert {
50 | padding-top: 2em;
51 | text-align: center;
52 | }
53 |
54 | &-content {
55 | padding: .5em 30px 4em 30px;
56 |
57 | &-buildinfo {
58 | float: right;
59 | }
60 |
61 | &-betalabel{
62 | color: #cd4d4d;
63 | font-weight: bold;
64 | }
65 |
66 | @media #{$medium-down} {
67 | padding-left: 20px;
68 | padding-right: 20px;
69 | }
70 |
71 | @media #{$small-down} {
72 | padding: 2em 25px;
73 | text-align: center;
74 |
75 | a { color: white; }
76 |
77 | &-buildinfo {
78 | float: none;
79 | padding-bottom: 1.5em;
80 | }
81 |
82 | li {
83 | font-size: 15px;
84 | }
85 | }
86 | }
87 |
88 | &-usermenu {
89 | position: relative;
90 | overflow: hidden;
91 | padding: 0 30px 0 30px;
92 | display: none;
93 |
94 | &-logout {
95 | float: right;
96 | }
97 |
98 | &-username {
99 | margin-left: 8px;
100 | font-weight: bold;
101 | }
102 |
103 | @media #{$medium-down} {
104 | padding-left: 20px;
105 | padding-right: 20px;
106 | display: block;
107 | }
108 |
109 | @media #{$small-down} {
110 | background: #494c4e;
111 | padding: 15px 15px 15px 70px;
112 |
113 | &-username {
114 | margin-left: 0;
115 | }
116 |
117 | a { color: white; }
118 |
119 | &-username i.fa {
120 | position: absolute;
121 | left: 6px;
122 | top: -15%;
123 | font-size: 60px;
124 | color: #696f72;
125 | }
126 |
127 | &-logout {
128 | color: white;
129 | background: #696f72;
130 | padding: 15px;
131 | margin: -15px;
132 | font-size: 18px;
133 |
134 | i.fa {
135 | margin-right: 0;
136 | }
137 |
138 | span {
139 | display: none;
140 | }
141 | }
142 | }
143 | }
144 | }
145 |
--------------------------------------------------------------------------------
/src/stylesheets/components/_function-filter.scss:
--------------------------------------------------------------------------------
1 | #fittable .functions-sidebar {
2 |
3 | div.function.function-filter {
4 |
5 | ul.filtering {
6 | list-style: none;
7 | margin: 0 0 20px 0;
8 | @include disable-selection();
9 |
10 | li {
11 | display: block;
12 | background: $lightgrayback-color;
13 | color: $darkgraytext-color;
14 | font-size: $large-font-size;
15 | font-weight: 300;
16 | line-height: 1.5;
17 | margin-bottom: 2px;
18 | transition: color $default-anim-speed, background $default-anim-speed;
19 | padding: 7px;
20 | cursor: pointer;
21 |
22 | &:hover {
23 | background: rgba( $lightgrayback-color, .75 );
24 | }
25 |
26 | i.fa {
27 | margin-right: 15px;
28 | }
29 |
30 | &:not(.active) i.fa {
31 | visibility: hidden;
32 | }
33 |
34 | &.active {
35 | background: $primary-color;
36 | color: white;
37 |
38 | &:hover {
39 | background: rgba( $primary-color, .75 );
40 | }
41 | }
42 | }
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/stylesheets/components/_function-settings.scss:
--------------------------------------------------------------------------------
1 | #fittable {
2 | .function-settings {
3 | padding-bottom: 30px;
4 | }
5 |
6 | .settings-toggle {
7 | margin-bottom: 20px;
8 | @include disable-selection();
9 | }
10 |
11 | .settings-toggle-btn {
12 | display: block;
13 | width: 100%;
14 | background: $light-secondary-color;
15 | color: $primary-color;
16 | padding: 4px 8px;
17 | margin-bottom: 1px;
18 |
19 | &:not(.active):hover {
20 | background: $secondary-color;
21 | }
22 |
23 | &.active {
24 | background: $primary-color;
25 | color: white;
26 | }
27 |
28 |
29 | i.fa {
30 | margin-right: 13px;
31 | font-size: 14px;
32 | }
33 | }
34 |
35 | }
36 |
--------------------------------------------------------------------------------
/src/stylesheets/components/_functions-bar-responsive.scss:
--------------------------------------------------------------------------------
1 | #fittable {
2 |
3 | // Medium screen
4 | @media #{$medium-down} {
5 |
6 | .function-btn {
7 | font-size: 1.25rem;
8 | padding: 0 15px;
9 |
10 | &:last-child {
11 | padding-right: 0;
12 | }
13 | }
14 | }
15 |
16 | // Small screen
17 | @media #{$small-down} {
18 |
19 | .functions-bar {
20 | float: none;
21 | width: 100%;
22 | background: $light-secondary-color;
23 | text-align: center;
24 | height: 45px;
25 | }
26 |
27 | .function-btn {
28 | font-size: 1.5rem;
29 | height: 45px;
30 | line-height: 45px;
31 | padding: 0 20px;
32 |
33 | &:last-child {
34 | padding: 0 20px;
35 | }
36 | }
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/stylesheets/components/_functions-bar.scss:
--------------------------------------------------------------------------------
1 | #fittable {
2 | .functions-bar {
3 | float: right;
4 | display: inline-block;
5 | height: $header-elements-weight;
6 | padding-right: 8px;
7 | position: relative;
8 | @include disable-selection();
9 | }
10 |
11 | .function-btn {
12 | @include active-light-hover;
13 | padding: 0 8px;
14 | height: $header-elements-weight;
15 | line-height: $header-elements-weight;
16 | position: relative;
17 | z-index: 199;
18 | background: transparent;
19 | }
20 |
21 | .function-btn .tooltip {
22 | @include bold-up-text();
23 | display: inline-block;
24 | position: absolute;
25 | top: -5px;
26 | text-align: right;
27 | line-height: 1;
28 | right: 0;
29 | opacity: 0;
30 | transition: top $default-anim-speed, opacity $default-anim-speed;
31 | height: 5px;
32 | z-index: 99;
33 |
34 | @media #{$medium-down} {
35 | display: none;
36 | }
37 | }
38 |
39 | .function-btn:hover .tooltip {
40 | opacity: 1;
41 | top: -15px;
42 | }
43 |
44 | .flags-function {
45 | display: none;
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/stylesheets/components/_functions-sidebar-responsive.scss:
--------------------------------------------------------------------------------
1 | #fittable {
2 |
3 | // Medium screen
4 | @media #{$medium-down} {
5 | .functions-sidebar.hide { display: none; }
6 |
7 | .functions-sidebar {
8 | padding: 30px;
9 | float: none;
10 | width: 100%;
11 | clear: both;
12 | border-bottom: 2px solid $primary-color;
13 | background: $light-secondary-color;
14 | position: absolute;
15 | z-index: 200;
16 | }
17 | }
18 |
19 | @media #{$small-down} {
20 |
21 | .functions-sidebar {
22 | margin: -15px 0 0 0;
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/stylesheets/components/_functions-sidebar.scss:
--------------------------------------------------------------------------------
1 | #fittable {
2 | .functions-sidebar {
3 | width: 25%;
4 | position: absolute;
5 | right: 0;
6 | padding: 0 0 0 25px;
7 | overflow: hidden;
8 | }
9 |
10 | @media screen and (max-width: 1200px) {
11 | .functions-sidebar { width: 40%; }
12 | }
13 |
14 | .functions-sidebar .wrap {
15 | opacity: 1;
16 | position: relative;
17 | left: 0;
18 | transition: left $default-anim-speed, opacity $default-anim-speed;
19 | transition-delay: $default-anim-speed / 2;
20 | }
21 |
22 | .functions-sidebar.hide {
23 | display: block;
24 | width: 0;
25 |
26 | .wrap {
27 | opacity: 0;
28 | left: 100px;
29 | }
30 | }
31 |
32 | .functions-sidebar h2 {
33 | color: $primary-color;
34 | font-weight: 300;
35 | font-size: $xlarge-font-size;
36 | margin: 0 0 15px;
37 | text-transform: lowercase;
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/stylesheets/components/_grid.scss:
--------------------------------------------------------------------------------
1 | .Grid-line {
2 | shape-rendering: crispEdges;
3 | }
4 |
--------------------------------------------------------------------------------
/src/stylesheets/components/_header-responsive.scss:
--------------------------------------------------------------------------------
1 | header {
2 |
3 | // Medium screen
4 | @media #{$medium-down} {
5 | &.row {
6 | margin: 15px auto 15px auto !important;
7 | text-align: center;
8 | }
9 |
10 | h1 {
11 | font-size: 23px;
12 | display: inline;
13 | }
14 |
15 | h1 + .sub-header {
16 | font-size: 10px;
17 | display: block;
18 | }
19 | }
20 |
21 | // Small screen
22 | @media #{$small-down} {
23 | &.row {
24 | background: $primary-color;
25 | margin: 0 !important;
26 | padding: 10px 20px 6px;
27 | }
28 |
29 | h1 {
30 | font-size: 15px;
31 | font-weight: 800;
32 | color: white;
33 | line-height: 1.3;
34 | }
35 |
36 | h1 + .sub-header {
37 | color: white;
38 | text-transform: none;
39 | line-height: 1;
40 | margin-top: -2px;
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/stylesheets/components/_header.scss:
--------------------------------------------------------------------------------
1 | header {
2 | position: relative;
3 |
4 | &.row {
5 | margin: 60px 0 30px 0 !important;
6 | }
7 |
8 | h1 {
9 | color: $primary-color;
10 | font-weight: 300;
11 | margin: -10px 0 0 0;
12 | }
13 |
14 | h1 + .sub-header {
15 | text-transform: uppercase;
16 | font-size: $small-font-size;
17 | margin-top: -7px;
18 | color: $primary-color;
19 | font-weight: 400;
20 | }
21 |
22 | .header-usermenu {
23 | float: right;
24 |
25 | @media #{$medium-down} {
26 | display: none;
27 | }
28 | }
29 |
30 | .header-usermenu-username {
31 | background: $light-secondary-color;
32 | @include upcase();
33 | padding: .5rem 1rem;
34 | height: 32px;
35 | float: left;
36 |
37 | a {
38 | color: $body-font-color;
39 | }
40 |
41 | i.fa {
42 | color: $primary-color;
43 | margin-right: .7rem;
44 | }
45 | }
46 | }
47 |
48 |
49 | #fittable {
50 |
51 | .header {
52 | position: relative;
53 |
54 | @media #{$small-down} {
55 | margin-bottom: 15px;
56 | }
57 |
58 | .function-controls {
59 | float: right;
60 | height: $header-elements-weight;
61 | width: 0;
62 | overflow: hidden;
63 | position: relative;
64 | transition: width $default-anim-speed, padding $default-anim-speed;
65 | padding-left: 0;
66 | box-sizing: border-box;
67 | margin: 0;
68 |
69 | &.open {
70 | width: 200px;
71 | padding-left: $header-elements-weight;
72 | }
73 |
74 | &.open.search {
75 | width: 160px;
76 | }
77 | }
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/stylesheets/components/_landing-page.scss:
--------------------------------------------------------------------------------
1 | #fittable-landing {
2 | margin-bottom: 60px;
3 |
4 | & > .row {
5 | max-width: 1100px;
6 | padding: 0 30px;
7 | }
8 |
9 | margin-top: 80px;
10 |
11 | .top-part {
12 | width: 70%;
13 | margin: 0 auto 100px auto;
14 | text-align: center;
15 |
16 | img {
17 | width: 100%;
18 | max-width: 300px;
19 | }
20 |
21 | button.login {
22 | @include button;
23 | font-weight: bold;
24 | margin-bottom: 7px;
25 | display: block;
26 |
27 | i.fa {
28 | margin-right: 15px;
29 | }
30 | }
31 |
32 | .access {
33 | margin: 130px 0 40px 0;
34 | }
35 | }
36 |
37 | p.help {
38 | font-size: 11px;
39 | text-align: center;
40 | color: #666;
41 | }
42 |
43 | div.vline {
44 | &::before {
45 | content: '';
46 | display: block;
47 | width: 50%;
48 | height: 650px;
49 | margin-top: -50px;
50 | border-right: 1px solid #999;
51 | }
52 | }
53 |
54 | div.hline {
55 | &::before {
56 | content: '';
57 | display: block;
58 | width: 100%;
59 | height: 50px;
60 | margin-bottom: 50px;
61 | border-bottom: 1px solid #666;
62 | }
63 | }
64 |
65 | h1.hero {
66 | color: $primary-color;
67 | font-size: 50px;
68 | font-weight: 100;
69 | margin-top: 0;
70 | }
71 |
72 | div.sub-hero {
73 | font-size: 20px;
74 | font-weight: 300;
75 | margin-bottom: 50px;
76 | line-height: 1.8;
77 | max-width: 700px;
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/stylesheets/components/_search.scss:
--------------------------------------------------------------------------------
1 | #fittable {
2 |
3 | $search-width: 360px;
4 | $background: $light-secondary-color;
5 |
6 | .Search {
7 | height: 32px;
8 | color: white;
9 | width: $search-width;
10 | float: right;
11 | position: relative;
12 | z-index: 300;
13 | margin-right: .5em;
14 |
15 | @media #{$medium-down} {
16 | position: absolute;
17 | top: .5em;
18 | right: 0;
19 | }
20 |
21 | @media #{$small-down} {
22 | width: 96%;
23 | right: 2%;
24 | left: 2%;
25 | }
26 | }
27 |
28 | .Search-icon {
29 | display: block;
30 | z-index: 320;
31 | position: absolute;
32 | line-height: 32px;
33 | right: 1em;
34 | color: $secondary-color;
35 | cursor: text;
36 |
37 | @media #{$medium-down} {
38 | cursor: pointer;
39 | }
40 | }
41 |
42 | .Search-input input {
43 | width: 72%;
44 | float: right;
45 | margin: 0;
46 | background: $background;
47 | color: $primary-color;
48 | line-height: 32px;
49 | height: 32px;
50 | border: 0;
51 | padding: 0 1em;
52 | transition: width $default-anim-speed, color $default-anim-speed, background $default-anim-speed;
53 | z-index: 310;
54 |
55 | @media #{$medium-down} {
56 | width: 3em;
57 | text-indent: 9999em;
58 | color: white;
59 | background: $primary-color;
60 | cursor: pointer;
61 | }
62 |
63 | &:focus, &:active, &:valid {
64 | outline: none;
65 | text-indent: 0;
66 | width: 100%;
67 | background: $background;
68 | color: $primary-color !important;
69 | cursor: text;
70 | }
71 |
72 | &:invalid {
73 | outline: none !important;
74 | box-shadow: none !important;
75 | }
76 | }
77 |
78 | .Search-results {
79 | @include disable-selection();
80 | width: $search-width;
81 | max-height: 420px;
82 | position: absolute;
83 | top: 32px;
84 | background: white;
85 | box-shadow: 0 1em 3em rgba(black, .1);
86 | transition: opacity $default-anim-speed;
87 | overflow-y: auto;
88 | overflow-x: hidden;
89 | padding: .25em;
90 | padding-bottom: 0;
91 |
92 | @media #{$small-down} {
93 | width: 100%;
94 | }
95 | }
96 |
97 | .Search-results:not(.is-active) {
98 | opacity: 0;
99 | }
100 |
101 | .Search-results ul {
102 | list-style: none;
103 | margin: 0;
104 | }
105 |
106 | .Search-result {
107 | display: block;
108 | width: 100%;
109 | background: $light-secondary-color;
110 | color: $primary-color;
111 | font-size: $large-font-size;
112 | font-weight: 300;
113 | line-height: 1.5;
114 | margin-bottom: 2px;
115 | transition: color $default-anim-speed, background $default-anim-speed;
116 | padding: 6px 1em;
117 |
118 | .subtext {
119 | font-size: $small-font-size;
120 | color: #333;
121 | }
122 |
123 | &:hover {
124 | background: $primary-color;
125 |
126 | &, .subtext {
127 | color: white;
128 | }
129 | }
130 | }
131 |
132 | }
133 |
--------------------------------------------------------------------------------
/src/stylesheets/components/_signpost-page.scss:
--------------------------------------------------------------------------------
1 | #fittable-signpost {
2 | padding-top: 2em;
3 | margin-bottom: 60px;
4 | max-width: 900px;
5 | margin: 0 auto;
6 | font-size: 16px;
7 |
8 | p {
9 | font-size: 16px;
10 | }
11 |
12 | h1 {
13 | text-align: center;
14 | padding-bottom: 1em;
15 | font-size: 2rem;
16 | }
17 |
18 | .langswitcher {
19 | text-align: right;
20 | padding-bottom: 1em;
21 | padding-bottom: 5vh;
22 | }
23 |
24 | .button {
25 | @include button;
26 | font-weight: bold;
27 | margin-bottom: 7px;
28 | display: block;
29 | font-size: 1.5em;
30 | i.fa {
31 | margin-right: 15px;
32 | }
33 | }
34 |
35 | .desc {
36 | padding-top: 1rem;
37 | }
38 |
39 | .button small {
40 | display: block;
41 | }
42 |
43 | .rooms-link {
44 | padding-top: 1rem;
45 | }
46 |
47 | @media #{$large-up} {
48 | & {
49 | padding-top: 15vh;
50 | }
51 | }
52 |
53 | .hidden {
54 | display: none;
55 | }
56 |
57 | }
58 |
--------------------------------------------------------------------------------
/src/stylesheets/components/_spinner.scss:
--------------------------------------------------------------------------------
1 | // SpinKit, thx -- tobiasahlin.com/spinkit
2 |
3 | .spinner {
4 | width: 120px;
5 | height: 25px;
6 | text-align: center;
7 | font-size: 10px;
8 | position: absolute;
9 | top: 50%;
10 | left: 50%;
11 | margin: 0 -60px;
12 | }
13 |
14 | .spinner > div {
15 | background-color: $third-color;
16 | height: 100%;
17 | width: 5px;
18 | display: inline-block;
19 | margin: 0 2px;
20 |
21 | -webkit-animation: stretchdelay 1.2s infinite ease-in-out;
22 | animation: stretchdelay 1.2s infinite ease-in-out;
23 | }
24 |
25 | .spinner .rect2 {
26 | -webkit-animation-delay: -1.1s;
27 | animation-delay: -1.1s;
28 | }
29 |
30 | .spinner .rect3 {
31 | -webkit-animation-delay: -1.0s;
32 | animation-delay: -1.0s;
33 | }
34 |
35 | .spinner .rect4 {
36 | -webkit-animation-delay: -0.9s;
37 | animation-delay: -0.9s;
38 | }
39 |
40 | .spinner .rect5 {
41 | -webkit-animation-delay: -0.8s;
42 | animation-delay: -0.8s;
43 | }
44 |
45 | @-webkit-keyframes stretchdelay {
46 | 0%, 80%, 100% { -webkit-transform: scaleY(0.2) }
47 | 40% { -webkit-transform: scaleY(1.0) }
48 | }
49 |
50 | @keyframes stretchdelay {
51 | 0%, 80%, 100% {
52 | transform: scaleY(0.2);
53 | -webkit-transform: scaleY(0.2);
54 | } 40% {
55 | transform: scaleY(1.0);
56 | -webkit-transform: scaleY(1.0);
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/stylesheets/components/_table-animations.scss:
--------------------------------------------------------------------------------
1 | $animation-speed: .25s;
2 |
3 | #fittable .events .event {
4 | // Without direction
5 | &.animnone-appear, &.animnone-enter {
6 | opacity: 0.01;
7 | }
8 |
9 | &.animnone-appear-active, &.animnone-enter-active {
10 | opacity: 1;
11 | transition: opacity $animation-speed;
12 | }
13 |
14 | &.animnone-leave {
15 | opacity: 1;
16 | }
17 |
18 | &.animnone-leave-active {
19 | opacity: 0.01;
20 | transition: opacity $animation-speed;
21 | }
22 |
23 | // Left direction
24 | &.animleft-appear, &.animleft-enter {
25 | opacity: 0.01;
26 | transform: translateX(15vw);
27 | }
28 |
29 | &.animleft-appear-active, &.animleft-enter-active {
30 | opacity: 1;
31 | transform: translateX(0);
32 | transition: opacity $animation-speed, transform $animation-speed;
33 | }
34 |
35 | &.animleft-leave {
36 | opacity: 1;
37 | transform: translateX(0);
38 | }
39 |
40 | &.animleft-leave-active {
41 | opacity: 0.01;
42 | transform: translateX(-15vw);
43 | transition: opacity $animation-speed/4, transform $animation-speed;
44 | }
45 |
46 | // Right direction
47 | &.animright-appear, &.animright-enter {
48 | opacity: 0.01;
49 | transform: translateX(-15vw);
50 | }
51 |
52 | &.animright-appear-active, &.animright-enter-active {
53 | opacity: 1;
54 | transform: translateX(0);
55 | transition: opacity $animation-speed, transform $animation-speed;
56 | }
57 |
58 | &.animright-leave {
59 | opacity: 1;
60 | transform: translateX(0);
61 | }
62 |
63 | &.animright-leave-active {
64 | opacity: 0.01;
65 | transform: translateX(15vw);
66 | transition: opacity $animation-speed/4, transform $animation-speed;
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/stylesheets/components/_table-appearances.scss:
--------------------------------------------------------------------------------
1 | #fittable {
2 |
3 | // Vertical
4 | .table--vertical .events .event {
5 |
6 | // Half
7 | &.half,
8 | &.half-first {
9 | width: 50%;
10 |
11 | &:not(.is-opened) .time,
12 | &:not(.is-opened) .room {
13 | display: none;
14 | }
15 |
16 | &:not(.is-opened) .name {
17 | font-size: 13px;
18 | }
19 | }
20 |
21 | &.half-first { padding-right: $events-gutter/2; }
22 |
23 | &.half-first + .event.half {
24 | left: auto;
25 | right: 0;
26 | padding-left: $events-gutter/2;
27 | }
28 |
29 | // Third
30 | &.third,
31 | &.third-first {
32 | width: 33%;
33 |
34 | &:not(.is-opened) .name {
35 | font-size: 13px;
36 | }
37 |
38 | &:not(.is-opened) .time,
39 | &:not(.is-opened) .room {
40 | display: none;
41 | }
42 | }
43 |
44 | &.third-first { padding-right: $events-gutter/2; }
45 |
46 | &.third-first + .event.third {
47 | left: 33%;
48 | padding: $events-gutter/2;
49 | }
50 |
51 | &.third-first + .third + .event.third {
52 | left: auto;
53 | right: 0;
54 | padding-left: $events-gutter/2;
55 | }
56 |
57 | // Quarter
58 | &.quarter,
59 | &.quarter-first {
60 | width: 25%;
61 |
62 | &:not(.is-opened) .time,
63 | &:not(.is-opened) .room {
64 | display: none;
65 | }
66 |
67 | &:not(.is-opened) .name,
68 | &:not(.is-opened) .type {
69 | font-size: 10px;
70 | }
71 | }
72 |
73 | &.quarter-first { padding-right: $events-gutter/2; }
74 |
75 | &.quarter-first + .event.quarter {
76 | left: 25%;
77 | padding: $events-gutter/2;
78 | }
79 |
80 | &.quarter-first + .quarter + .event.quarter {
81 | left: 50%;
82 | padding: $events-gutter/2;
83 | }
84 |
85 | &.quarter-first + .quarter + .quarter + .event.quarter {
86 | left: auto;
87 | right: 0;
88 | padding-left: $events-gutter/2;
89 | }
90 | }
91 |
92 | // Horizontal
93 | .table--horizontal .events .event {
94 |
95 | // Half
96 | &.half,
97 | &.half-first {
98 | height: 50%;
99 |
100 | &:not(.is-opened) .name {
101 | font-size: 14px;
102 | margin-top: 0;
103 | }
104 |
105 | &:not(.is-opened) .type {
106 | font-size: 10px;
107 | bottom: 7px;
108 | left: 10px;
109 | }
110 |
111 | &:not(.is-opened) .time {
112 | font-size: 10px;
113 | }
114 |
115 | &:not(.is-opened) .room {
116 | display: none;
117 | }
118 | }
119 |
120 | &.half-first { padding-bottom: $events-gutter/4; }
121 |
122 | &.half-first + .event.half {
123 | top: auto;
124 | bottom: 0;
125 | padding-top: $events-gutter/4;
126 | }
127 |
128 | // Third
129 | &.third,
130 | &.third-first {
131 | height: 33%;
132 |
133 | &:not(.is-opened) .name {
134 | font-size: 12px;
135 | margin-top: -2px;
136 | margin-right: -3px;
137 | }
138 |
139 | &:not(.is-opened) .type,
140 | &:not(.is-opened) .time,
141 | &:not(.is-opened) .room {
142 | display: none;
143 | }
144 | }
145 |
146 | &.third-first { padding-bottom: 0; }
147 |
148 | &.third-first + .event.third {
149 | top: 33%;
150 | padding: $events-gutter/2;
151 | }
152 |
153 | &.third-first + .third + .event.third {
154 | top: auto;
155 | bottom: 0;
156 | padding-top: 2px;
157 | }
158 |
159 | // Quarter
160 | &.quarter,
161 | &.quarter-first{
162 | height: 25%;
163 |
164 | &:not(.is-opened) .name {
165 | font-size: 9px;
166 | margin-top: -4px;
167 | }
168 |
169 | &:not(.is-opened) .type,
170 | &:not(.is-opened) .time,
171 | &:not(.is-opened) .room {
172 | display: none;
173 | }
174 | }
175 |
176 | &.quarter-first { padding-bottom: 0; }
177 |
178 | &.quarter-first + .event.quarter {
179 | top: 25%;
180 | padding-top: $events-gutter/1.5;
181 | padding-bottom: $events-gutter/3;
182 | }
183 |
184 | &.quarter-first + .quarter + .event.quarter {
185 | top: 50%;
186 | padding-bottom: $events-gutter/1.5;
187 | padding-top: $events-gutter/3;
188 | }
189 |
190 | &.quarter-first + .quarter + .quarter + .event.quarter {
191 | top: auto;
192 | bottom: 0;
193 | padding-top: 0;
194 | }
195 | }
196 | }
197 |
--------------------------------------------------------------------------------
/src/stylesheets/components/_table-events-colors.scss:
--------------------------------------------------------------------------------
1 | #fittable div.table div.day {
2 |
3 | $lecture: #F7C93F;
4 | $tutorial: #64AA47;
5 | $laboratory: #DE5543;
6 | $exam: #9169B5;
7 | $teacher-timetable-slot: #808A90;
8 |
9 | .event {
10 |
11 | &--lecture {
12 |
13 | .inner {
14 | background: $lecture;
15 | color: #444;
16 | }
17 |
18 | &:hover .inner {
19 | background: lighten( $lecture, 10% );
20 | }
21 | }
22 |
23 | &--tutorial {
24 |
25 | .inner {
26 | background: $tutorial;
27 | }
28 |
29 | &:hover .inner {
30 | background: lighten( $tutorial, 10% );
31 | }
32 | }
33 |
34 | &--laboratory {
35 |
36 | .inner {
37 | background: $laboratory;
38 | }
39 |
40 | &:hover .inner {
41 | background: lighten( $laboratory, 10% );
42 | }
43 | }
44 |
45 | &--exam {
46 |
47 | .inner {
48 | background: $exam;
49 | }
50 |
51 | &:hover .inner {
52 | background: lighten( $exam, 10% );
53 | }
54 | }
55 |
56 | &--teacher-timetable-slot {
57 |
58 | .inner {
59 | background: $teacher-timetable-slot;
60 | }
61 |
62 | &:hover .inner {
63 | background: lighten( $teacher-timetable-slot, 10% );
64 | }
65 | }
66 |
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/stylesheets/components/_table-events-detail.scss:
--------------------------------------------------------------------------------
1 | #fittable .table .events .event {
2 |
3 | .detail {
4 | visibility: hidden;
5 | background: white;
6 | clear: both;
7 | min-height: 0;
8 | height: 0;
9 | color: #222;
10 | width: inherit;
11 | opacity: 0;
12 | overflow-x: hidden;
13 | overflow-y: auto;
14 |
15 | .wrap {
16 | padding: 20px;
17 | }
18 |
19 | $value-font-size: 15px;
20 |
21 | button {
22 | @extend %btn-link;
23 | }
24 |
25 | .prop-section {
26 |
27 | &:not(.teachers) {
28 | margin-bottom: 13px;
29 | padding-bottom: 13px;
30 | border-bottom: 1px solid #ddd;
31 | }
32 | }
33 |
34 | .prop-title {
35 | color: $dark-secondary-text-color;
36 | text-transform: lowercase;
37 | }
38 |
39 | .basic-props {
40 | .type-num {
41 | color: $dark-secondary-text-color;
42 | }
43 |
44 | .name {
45 | font-size: 15px;
46 | }
47 |
48 | .location button {
49 | color: $body-font-color;
50 |
51 | i.fa {
52 | margin-right: 3px;
53 | }
54 |
55 | &:hover {
56 | opacity: .7;
57 | }
58 | }
59 | }
60 |
61 | .exceptions {
62 | .reveal {
63 | color: $body-font-color;
64 |
65 | i.fa {
66 | margin-right: 5px;
67 | }
68 |
69 | i.fa.ex-icon {
70 | color: #cd8f47;
71 | }
72 |
73 | &:hover {
74 | opacity: .7;
75 | }
76 | }
77 | }
78 |
79 | .num-props {
80 | position: relative;
81 | min-height: 50px;
82 |
83 | .num-prop {
84 | float: none !important;
85 | top: 0;
86 |
87 | .value {
88 | font-size: $value-font-size;
89 | }
90 |
91 | &.left {
92 | width: 33%;
93 | position: absolute;
94 | left: 0;
95 | }
96 |
97 | &.center {
98 | width: 33%;
99 | margin: auto;
100 | text-align: center;
101 | }
102 |
103 | &.right {
104 | width: 33%;
105 | position: absolute;
106 | right: 0;
107 | text-align: right;
108 | }
109 | }
110 | }
111 |
112 | .teachers {
113 |
114 | .reveal {
115 | color: $body-font-color;
116 | font-size: $value-font-size;
117 | margin-left: 32px;
118 |
119 | i.fa {
120 | margin-right: 8px;
121 | }
122 |
123 | i.fa:first-of-type {
124 | margin-left: -32px;
125 | }
126 |
127 | &:hover {
128 | opacity: .7;
129 | }
130 | }
131 |
132 | .hideable {
133 | margin: 3px 0px 7px 34px;
134 | }
135 | }
136 | }
137 |
138 | @keyframes open {
139 | 0% {
140 | transform: scaleX(1) scaleY(.25);
141 | }
142 |
143 | 100% {
144 | transform: scale(1);
145 | }
146 | }
147 |
148 | &.is-opened {
149 | width: 300px !important;
150 | height: auto !important;
151 | z-index: 999;
152 | overflow: hidden;
153 | animation: open $events-move-anim-speed ease-out;
154 |
155 | .detail {
156 | visibility: visible;
157 | opacity: 1;
158 | min-height: 180px;
159 | height: auto;
160 | margin: 0 0 5px 0;
161 | }
162 |
163 | .type {
164 | display: none;
165 | }
166 | }
167 | }
168 |
169 |
170 | @keyframes open-mobile {
171 | from {
172 | transform: scaleX(1) scaleY(.3);
173 | }
174 |
175 | to {
176 | transform: scale(1);
177 | }
178 | }
179 |
180 | #fittable .table.table--small .events .event.is-opened {
181 | width: 100% !important;
182 | animation-name: open-mobile;
183 | }
184 |
--------------------------------------------------------------------------------
/src/stylesheets/components/_table-events.scss:
--------------------------------------------------------------------------------
1 | #fittable .events {
2 | width: 100%;
3 | float: left;
4 | position: relative;
5 | height: 93%;
6 | margin: 0;
7 |
8 | .event {
9 | position: absolute;
10 | padding: $events-gutter/2 $events-gutter;
11 | z-index: 99;
12 | width: 100%;
13 | box-sizing: border-box;
14 | transition: opacity $default-anim-speed;
15 | transform-origin: 0 0;
16 |
17 | &.hide, &:not(.is-opened).hide {
18 | display: block;
19 | opacity: 0 !important;
20 | }
21 |
22 | .inner {
23 | background: $primary-color;
24 | color: white;
25 | height: 100%;
26 | @include background-hover-effect( $primary-color );
27 | overflow: hidden;
28 | position: relative;
29 | line-height: 1.35;
30 | }
31 |
32 | &:not(.event--cancelled) .cancelflag {
33 | display: none;
34 | }
35 |
36 | &:not(.event--replacement) .replaceflag {
37 | display: none;
38 | }
39 |
40 | .cancelflag, .replaceflag {
41 | color: white;
42 | opacity: .5;
43 | font-size: 48px;
44 | position: absolute;
45 | top: -10px;
46 | left: -10px;
47 | }
48 |
49 | &--cancelled .inner {
50 | border-top: 3px dashed rgba(white, .5);
51 | background: $third-color;
52 | color: white;
53 | }
54 |
55 | &--replacement .inner {
56 | border-top: 3px dashed rgba(white, .5);
57 | color: white;
58 | }
59 |
60 | .head-space {
61 | padding: $events-gutter/2 $events-gutter;
62 | }
63 |
64 | &:not(.hide) div.inner div.head-space {
65 | width: 100%;
66 | cursor: pointer;
67 | }
68 |
69 | &:not(.is-opened) div.inner div.head-space {
70 | height: 100%;
71 | }
72 |
73 | .head-name {
74 | font-size: $xlarge-font-size;
75 | font-weight: 300;
76 | text-align: right;
77 | margin-top: 5px;
78 | white-space: nowrap;
79 | overflow: hidden;
80 | @include disable-selection();
81 | }
82 |
83 | .head-time {
84 | font-size: $xsmall-font-size;
85 | text-align: right;
86 | @include disable-selection();
87 | }
88 |
89 | .head-room {
90 | font-size: $xsmall-font-size;
91 | text-align: right;
92 | @include disable-selection();
93 |
94 | i {
95 | margin-right: .5em;
96 | }
97 | }
98 |
99 | .head-type {
100 | position: absolute;
101 | bottom: 10px;
102 | left: 15px;
103 | font-size: $small-font-size;
104 | font-weight: 700;
105 | border-left: 2px solid white;
106 | padding-left: 5px;
107 | line-height: 1.2;
108 | @include disable-selection();
109 | }
110 |
111 | &.is-opened div.inner div.head-space {
112 | background: transparent;
113 | transition: background $default-anim-speed;
114 |
115 | &:hover {
116 | background: rgba( white, 0.1 );
117 |
118 | }
119 | }
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/src/stylesheets/components/_table-horizontal.scss:
--------------------------------------------------------------------------------
1 | #fittable .table--horizontal {
2 |
3 | .day {
4 | height: $day-row-height;
5 | width: 100%;
6 |
7 | .label {
8 | float: left;
9 | padding: $day-row-padding*2 0;
10 | margin: 0;
11 | width: 10%;
12 | height: 100%;
13 |
14 | .label-wrap {
15 | padding: 0 $day-row-padding;
16 | line-height: $day-row-height - $day-row-padding*4;
17 | }
18 | }
19 |
20 | &.active .label-wrap {
21 | border-top: 0;
22 | border-left: 3px solid $primary-color;
23 | }
24 | }
25 |
26 | .events {
27 | width: 90%;
28 | height: 100%;
29 | float: left;
30 | }
31 |
32 | .event {
33 | padding: $events-gutter $events-gutter/2;
34 |
35 | &.regular:not(.is-opened) .inner {
36 | height: $day-row-height - $events-gutter*2;
37 | }
38 | }
39 |
40 | .grid-wrapper {
41 | width: 90%;
42 | height: 100%;
43 | left: 10%;
44 | top: 0;
45 | padding: $day-row-padding 0;
46 | }
47 |
48 | .grid {
49 | background-color: transparent;
50 | background-image: linear-gradient(90deg, $grid-color 0px, $grid-color $grid-line-width, transparent $grid-line-width );
51 | background-position: top left;
52 | width: 100%;
53 | height: 100%;
54 | }
55 |
56 | .now-indicator-wrap {
57 | top: 0;
58 | left: 10%;
59 | width: 90%;
60 | height: 100%;
61 | }
62 |
63 | .now-indicator {
64 | border-bottom: none;
65 | border-right: $nowindicator-thickness solid $nowindicator-color;
66 |
67 | &::after {
68 | left: auto;
69 | bottom: auto;
70 | top: -$nowindicator-thickness;
71 | right: -$nowindicator-thickness*2;
72 | }
73 | }
74 |
75 | .hour-labels {
76 | bottom: -2.5rem;
77 | top: auto;
78 | left: 10%;
79 | height: 2rem;
80 | width: 90%;
81 | }
82 |
83 | .HourLabel {
84 | text-align: left;
85 | height: 2rem;
86 | margin: 0 0 0 -3px;
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/src/stylesheets/components/_table-responsive.scss:
--------------------------------------------------------------------------------
1 | #fittable .table {
2 | // Medium screen
3 | @media #{$medium-down} {
4 | .hour-labels {
5 | left: -2.25rem;
6 | }
7 |
8 | .HourLabel {
9 | font-size: $small-font-size * 0.75;
10 | }
11 | }
12 |
13 | // Small screen
14 | @media #{$small-down} {
15 | width: 90%;
16 | margin-left: 10%;
17 |
18 | .day {
19 | background: white !important;
20 | width: 100% !important; // table.is-7days has higher priority than this
21 | }
22 | .day:not(.selected) { display: none; }
23 | .day .label { display: none; }
24 |
25 | .now-indicator {
26 | width: 98% !important;
27 | left: 2% !important;
28 | }
29 |
30 | .hour-labels {
31 | top: 0%;
32 | }
33 |
34 | .grid-wrapper {
35 | top: 0%;
36 | }
37 |
38 | .now-indicator-wrap {
39 | top: 0;
40 | left: 0;
41 | width: 100%;
42 | height: 93%;
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/stylesheets/components/_table.scss:
--------------------------------------------------------------------------------
1 | #fittable .table {
2 | position: relative;
3 | clear: left;
4 | width: 100%;
5 |
6 | &.is-cut { width: 75%; }
7 |
8 | @media screen and (max-width: 1200px) {
9 | &.is-cut { width: 60%; }
10 | }
11 |
12 | &.is-muted {
13 | .grid-wrapper,
14 | .now-indicator,
15 | .label,
16 | .hour-labels,
17 | .event:not(.is-opened){ opacity: .3 !important; }
18 | .days .day:nth-child(odd) { background: rgba($secondary-color, .3); }
19 | }
20 |
21 | .day {
22 | max-height: $vertical-layout-day-height;
23 | background: white;
24 | color: $primary-color;
25 | height: $vertical-layout-day-height;
26 | width: 20%;
27 | float: left;
28 |
29 | .label {
30 | display: block;
31 | background: none;
32 | text-align: left;
33 | color: $primary-color;
34 | position: relative;
35 | z-index: 12;
36 | float: none;
37 | width: 100%;
38 | line-height: 1.7;
39 | height: 7%;
40 | margin: 0;
41 | overflow: hidden;
42 | padding: 0 $day-row-padding;
43 | @include disable-selection();
44 |
45 | span {
46 | padding: 5px 0 5px 20px;
47 | margin-left: -18px;
48 | }
49 | }
50 |
51 | .day-num {
52 | display: inline;
53 | font-weight: bold;
54 | margin-right: 5px;
55 | }
56 |
57 | .label-wrap {
58 | padding: $day-row-padding 0;
59 | }
60 |
61 | &.active {
62 | .day-num {
63 | font-weight: 700;
64 | margin-left: -14px;
65 | padding-left: 14px;
66 | }
67 |
68 | .day-name {
69 | font-weight: 700;
70 | }
71 |
72 | .label-wrap {
73 | border-top: 3px solid $primary-color;
74 | }
75 | }
76 | }
77 |
78 | &.is-7days:not(.table--horizontal) .day {
79 | width: 14.28%;
80 | }
81 |
82 | .event .type {
83 | bottom: 15px;
84 | }
85 |
86 | .day:nth-child(odd) {
87 | background: $light-secondary-color;
88 | }
89 |
90 | .grid-overlay {
91 | position: absolute;
92 | left: 0;
93 | top: 0;
94 | width: 100%;
95 | height: 100%;
96 | z-index: 6;
97 | }
98 |
99 | .grid-wrapper {
100 | width: 100%;
101 | height: 93%;
102 | position: relative;
103 | left: 0;
104 | top: 7%;
105 | padding: 0 $day-row-padding;
106 | }
107 |
108 | .grid {
109 | position: relative;
110 | width: 100%;
111 | height: 100%;
112 | }
113 |
114 | .now-indicator-wrap {
115 | top: 7%;
116 | left: 0;
117 | width: 100%;
118 | height: 93%;
119 | position: absolute;
120 | }
121 |
122 | .now-indicator {
123 | position: absolute;
124 | left: 0;
125 | top: $nowindicator-thickness;
126 | height: 107%;
127 | z-index: 5;
128 | border-bottom: $nowindicator-thickness solid $nowindicator-color;
129 | opacity: 1;
130 | @include disable-selection();
131 |
132 | &::after {
133 | content: '';
134 | display: block;
135 | width: $nowindicator-thickness*3;
136 | height: $nowindicator-thickness*3;
137 | background: $nowindicator-color;
138 | border-radius: 50%;
139 | position: absolute;
140 | bottom: -$nowindicator-thickness*2;
141 | left: -$nowindicator-thickness*2;
142 | }
143 | }
144 |
145 | .hour-labels {
146 | @include disable-selection();
147 | z-index: 10;
148 | left: -2.5rem;
149 | top: 7%;
150 | width: 2rem;
151 | position: absolute;
152 | height: 93%;
153 | text-align: right;
154 | }
155 |
156 | .HourLabel {
157 | position: absolute;
158 | color: $lightgraytext-color;
159 | font-size: $small-font-size;
160 | text-align: right;
161 | left: 0;
162 | width: 25px;
163 | margin-top: -10px;
164 | }
165 |
166 | .event {
167 | &.hide, &:not(.detail-shown).hide {
168 | display: block;
169 | opacity: 0 !important;
170 | }
171 | }
172 | }
173 |
--------------------------------------------------------------------------------
/src/stylesheets/components/_ukraine-ribbon.scss:
--------------------------------------------------------------------------------
1 | // Based on https://github.com/hejny/Ukraine
2 | #ukraine-ribbon {
3 | position: absolute;
4 | top: 0;
5 | left: 0;
6 | transform: translateX(-45%);
7 |
8 | & > a {
9 | display: block;
10 | width: 100vw;
11 | height: 3vw;
12 | transform: rotate(-45deg);
13 | box-shadow: 0 0 8px rgba(0,0,0,0.5);
14 |
15 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1200 800'%3E%3Crect width='1200' height='800' fill='%23005BBB'/%3E%3Crect width='1200' height='400' y='400' fill='%23FFD500'/%3E%3C/svg%3E");
16 | background-size: auto;
17 |
18 | @media #{$xxlarge-up} {
19 | width: 1600px;
20 | height: 48px;
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/stylesheets/components/_week-nav-responsive.scss:
--------------------------------------------------------------------------------
1 | #fittable {
2 |
3 | // Small screen
4 | @media #{$small-down} {
5 |
6 | .week-nav {
7 | width: 100%;
8 | }
9 |
10 | .week-nav-btn {
11 | width: 25%;
12 | }
13 |
14 | .week-nav-btn.week-nav-calendar {
15 | width: 50%;
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/stylesheets/components/_week-nav.scss:
--------------------------------------------------------------------------------
1 | #fittable {
2 | .week-nav {
3 | float: left;
4 | z-index: 420;
5 | }
6 |
7 | .week-nav-btn {
8 | background: $primary-color;
9 | width: 35px;
10 | height: 35px;
11 | color: white;
12 | text-align: center;
13 | line-height: $header-elements-weight;
14 | font-size: $large-font-size;
15 | @include background-hover-effect($primary-color);
16 | @include upcase;
17 |
18 | &.week-nav-calendar {
19 | width: 170px;
20 | }
21 | }
22 |
23 | @media #{$large-up} {
24 | .week-toggle-dow {
25 | display: none;
26 | }
27 |
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/stylesheets/components/_week-switcher.scss:
--------------------------------------------------------------------------------
1 | #fittable {
2 |
3 | .weeksw {
4 | @include active-dark;
5 | @include disable-selection();
6 | position: absolute;
7 | top: 35px;
8 | width: 240px;
9 | height: 240px;
10 | z-index: 390;
11 | padding: 20px $column-gutter/2;
12 | opacity: 1;
13 | transition: opacity $default-anim-speed, height $default-anim-speed;
14 |
15 | @media #{$small-down} {
16 | width: 100%;
17 | }
18 | }
19 |
20 | // FIXME: this overrides foundation class; perhaps use a better class name?
21 | .weeksw.hide {
22 | display: block !important;
23 | visibility: hidden;
24 | opacity: 0;
25 | height: 0;
26 | padding: 0;
27 | overflow: hidden;
28 | }
29 |
30 | .weeksw-semester-selector,
31 | .weeksw-month-selector {
32 | text-align: center;
33 |
34 | .active-item {
35 | color: white;
36 | font-weight: bold;
37 | }
38 |
39 | .gr-go-btn {
40 | @extend %btn-dark;
41 | width: 100%;
42 | height: 100%;
43 | display: block;
44 | text-align: center;
45 | }
46 | }
47 |
48 | .weeksw-semester-selector {
49 | margin-bottom: 5px;
50 | }
51 |
52 | .weeksw-month-selector {
53 | margin-bottom: 17px;
54 | }
55 |
56 | .weeksw-week-selector {
57 | @include active-dark;
58 | margin-bottom: 2px;
59 | }
60 |
61 | .weeksw-week-selector:hover .weeksw-day {
62 | background: rgba(255,255,255,.1);
63 | }
64 |
65 | .weeksw-week-selector.active-week {
66 | .weeksw-day {
67 | @include active-light;
68 | }
69 | .weeksw-day.in-other {
70 | color: rgba( $primary-color ,.3); // FIXME: this is close to a shared hover style
71 | }
72 | }
73 |
74 | .weeksw-day {
75 | display: inline-block;
76 | width: 14.285%; // ~ 100 / 7
77 | text-align: center;
78 | }
79 |
80 | .weeksw-day.in-other {
81 | color: rgba(255,255,255,.3);
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/src/stylesheets/fittable.scss:
--------------------------------------------------------------------------------
1 | @import "settings";
2 | @import "normalize";
3 | @import "reset";
4 |
5 | // Behold, here are all the Foundation components.
6 | @import "foundation/components/grid";
7 | //@import "foundation/components/accordion";
8 | //@import "foundation/components/alert-boxes";
9 | @import "foundation/components/block-grid";
10 | //@import "foundation/components/breadcrumbs";
11 | //@import "foundation/components/button-groups";
12 | @import "foundation/components/buttons";
13 | //@import "foundation/components/clearing";
14 | //@import "foundation/components/dropdown";
15 | //@import "foundation/components/dropdown-buttons";
16 | //@import "foundation/components/flex-video";
17 | //@import "foundation/components/forms";
18 | @import "foundation/components/icon-bar";
19 | //@import "foundation/components/inline-lists";
20 | //@import "foundation/components/joyride";
21 | //@import "foundation/components/keystrokes";
22 | //@import "foundation/components/labels";
23 | //@import "foundation/components/magellan";
24 | //@import "foundation/components/orbit";
25 | //@import "foundation/components/pagination";
26 | //@import "foundation/components/panels";
27 | //@import "foundation/components/pricing-tables";
28 | //@import "foundation/components/progress-bars";
29 | //@import "foundation/components/range-slider";
30 | //@import "foundation/components/reveal";
31 | //@import "foundation/components/side-nav";
32 | //@import "foundation/components/split-buttons";
33 | //@import "foundation/components/sub-nav";
34 | @import "foundation/components/switches";
35 | //@import "foundation/components/tables";
36 | //@import "foundation/components/tabs";
37 | //@import "foundation/components/thumbs";
38 | //@import "foundation/components/tooltips";
39 | //@import "foundation/components/top-bar";
40 | @import "foundation/components/type";
41 | //@import "foundation/components/offcanvas";
42 | @import "foundation/components/visibility";
43 |
44 | @import "base";
45 | @import "mixins";
46 | @import "components";
47 |
--------------------------------------------------------------------------------
/src/time.js:
--------------------------------------------------------------------------------
1 | export function convertSecondsToTime (seconds) {
2 | return {
3 | h: Math.floor(seconds / 3600),
4 | m: Math.floor((seconds % 3600) / 60),
5 | s: seconds % 3600 % 60,
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/utils.js:
--------------------------------------------------------------------------------
1 | import R from 'ramda'
2 |
3 | // Because R.addIndex(R.forEach) is very slow...
4 | export const forEachWithIndex = R.curry(
5 | (func, list) => list.forEach(func)
6 | )
7 |
8 | /**
9 | * Groups the elements of the list according to the result of calling
10 | * the String-returning function `keyFn` on each element and reduces the elements
11 | * of each group to a single value via the reducer function `valueFn`.
12 | *
13 | * This function is basically a more general `groupBy` function.
14 | *
15 | * TODO: Remove after https://github.com/ramda/ramda/pull/1598 is merged into Ramda.
16 | *
17 | * @sig (b -> String) -> ((a, b) -> a) -> a -> [b] -> {String: a}
18 | */
19 | export const reduceBy = R.curry((keyFn, valueFn, valueAcc, list) => {
20 | return R.reduce((acc, elt) => {
21 | const key = keyFn(elt)
22 | acc[key] = valueFn(acc[key] || valueAcc, elt)
23 | return acc
24 | }, {}, list)
25 | })
26 |
27 | /**
28 | * Creates a new object with the own properties of the provided object, but the
29 | * keys renamed according to the keysMap object as `{oldKey: newKey}`.
30 | * When some key is not found in the keysMap, then it's passed as-is.
31 | *
32 | * Note that conflicting keys cause an undefined behaviour and the result
33 | * may vary.
34 | *
35 | * @sig {a: b} -> {a: *} -> {b: *}
36 | */
37 | export const renameKeys = R.curry((keysMap, obj) => {
38 | return R.reduce((acc, key) => {
39 | acc[keysMap[key] || key] = obj[key]
40 | return acc
41 | }, {}, R.keys(obj))
42 | })
43 |
--------------------------------------------------------------------------------
/src/utils/safeExpandingDirection.js:
--------------------------------------------------------------------------------
1 | /**
2 | * This function receives length of an outer line (available space), point on
3 | * the line and length of an inner line. One of the inner line's end must be
4 | * placed at the point. The function returns which one is better to maximize
5 | * lines overlap: -1 for right, +1 for left, or 0 when it doesn't matter.
6 | */
7 | function whereOverflowLess (position, innerLength, outerLength) {
8 | const leftOverflow = innerLength - position
9 | const rightOverflow = innerLength - (outerLength - position)
10 |
11 | if (leftOverflow === rightOverflow || leftOverflow < 0 && rightOverflow < 0) {
12 | return 0
13 | } else if (leftOverflow < rightOverflow) {
14 | return -1
15 | } else {
16 | return 1
17 | }
18 | }
19 |
20 | /**
21 | * This function receives a window (outer rectangle), coordinates of a point inside the
22 | * window, size of a rectangle that should fit into the window (if possible), and a default
23 | * value. One of the rectangle's corner must be placed at the point; the function returns which
24 | * one, but inverted. Returns the provided default, if fits. If not and a better option exists,
25 | * then it returns the better one.
26 | *
27 | * For example, if the inner rectangle doesn't fit the window and overflows on the right side,
28 | * the function returns `{ right: false, bottom: true }` (when `bottom: true` is the default),
29 | * which means it should expand to the bottom-left.
30 | *
31 | * @param {Array} point A tuple with x and y coordinates.
32 | * @param {Array} size A tuple with width and height.
33 | * @param window The browser's window object.
34 | * @param defaultDir The default direction; expects an object with boolean properties "bottom"
35 | * and "right".
36 | * @returns A direction for expanding; an object with boolean properties "bottom" and "right".
37 | */
38 | function safeExpandingDirection ([x, y], [width, height], window, defaultDir) {
39 | const { innerWidth, innerHeight } = window
40 |
41 | // Get best directions to place the rectangle.
42 | const bestHorizontal = whereOverflowLess(x, width, innerWidth)
43 | const bestVertical = whereOverflowLess(y, height, innerHeight)
44 |
45 | // Set best direction, or leave the defaults.
46 | const right = bestHorizontal !== 0 ? bestHorizontal === 1 : defaultDir.right
47 | const bottom = bestVertical !== 0 ? bestVertical === 1 : defaultDir.bottom
48 |
49 | return { right, bottom }
50 | }
51 |
52 | export default safeExpandingDirection
53 |
--------------------------------------------------------------------------------
/src/utils/suitClassName.js:
--------------------------------------------------------------------------------
1 | import R from 'ramda'
2 |
3 | const keysOfTruthyValues = R.pipe(
4 | R.filter(R.identity),
5 | R.keys
6 | )
7 |
8 | /**
9 | * Generate a class name according to SUIT CSS naming convention.
10 | *
11 | * @param {string} componentName The component name.
12 | * @param {string} [descendentName] The descendent element name.
13 | * @param {string[]} [modifiers] The element modifiers.
14 | * @param {object} [states] The states of the component, e.g. `{ selected: true } -> '.is-selected'`
15 | * @return {string} A class name.
16 | */
17 | function suitClassName (componentName, descendentName = null, modifiers = [], states = {}) {
18 | const elementName = componentName + (descendentName ? `-${descendentName}` : '')
19 |
20 | // Construct a strings of modifiers and states.
21 | const classes = R.concat(
22 | R.map(R.concat(`${elementName}--`), modifiers),
23 | R.map(R.concat('is-'), keysOfTruthyValues(states))
24 | )
25 |
26 | // Join the element name with descendents and modifiers with states.
27 | return [elementName, ...classes].join(' ')
28 | }
29 |
30 | export default suitClassName
31 |
--------------------------------------------------------------------------------
/test/.eslintrc.yaml:
--------------------------------------------------------------------------------
1 | rules:
2 | comma-spacing: [0]
3 | no-multi-spaces: [0]
4 | spaced-comment: [0]
5 | standard/array-bracket-even-spacing: [0]
6 |
--------------------------------------------------------------------------------
/test/actions/clientActions.test.js:
--------------------------------------------------------------------------------
1 | import test from 'blue-tape'
2 | import { spy } from 'sinon'
3 | import { CLIENT_CHANGE } from '../../src/constants/actionTypes'
4 | import * as actions from '../../src/actions/clientActions'
5 | import { SMALL_SCREEN, LARGE_SCREEN } from '../../src/constants/screenSizes'
6 |
7 | test('detectScreenSize()', t => {
8 | const thunk = actions.detectScreenSize()
9 |
10 | const dispatch = spy()
11 | const getState = () => { return {client: {screenSize: SMALL_SCREEN}} }
12 | const getSecondState = () => { return {client: {screenSize: LARGE_SCREEN}} }
13 |
14 | // stubbing global window object
15 | global.window = {innerWidth: 1280}
16 | thunk(dispatch, getState)
17 | global.window = {innerWidth: 1290}
18 | thunk(dispatch, getSecondState)
19 |
20 | t.equal(dispatch.callCount, 1, 'dispatches only once if the current state is different')
21 |
22 | const expectedArg = {type: CLIENT_CHANGE, payload: {screenSize: LARGE_SCREEN}}
23 | const [actualArg] = dispatch.firstCall.args
24 | t.deepEqual(actualArg, expectedArg, 'dispatches CLIENT_CHANGE with smallScreen payload')
25 |
26 | t.end()
27 | })
28 |
--------------------------------------------------------------------------------
/test/actions/dataActions.test.js:
--------------------------------------------------------------------------------
1 | import test from 'blue-tape'
2 | import { spy } from 'sinon'
3 | import * as actions from '../../src/actions/dataActions'
4 | import {
5 | EVENTS_LOAD_STARTED,
6 | EVENTS_LOAD_COMPLETED,
7 | EVENTS_LOAD_FAILED,
8 | DATA_ERROR_HIDE,
9 | } from '../../src/constants/actionTypes'
10 |
11 | test('fetchEvents() executes a given callback with a week range', t => {
12 | const params = {
13 | type: 'courses',
14 | id: 'MI-RUB',
15 | date: '2015-09-09',
16 | }
17 |
18 | const expectedFrom = '2015-09-07'
19 | const expectedTo = '2015-09-13'
20 |
21 | const dataCallback = (actualParams, cb) => {
22 | t.equal(actualParams.dateFrom, expectedFrom,
23 | 'callback receives dateFrom string for a week start')
24 | t.equal(actualParams.dateTo, expectedTo, 'callback receives dateTo string for a week end')
25 | t.equal(typeof cb, 'function', 'callback receives a callback for response')
26 | t.end()
27 | }
28 |
29 | const thunk = actions.fetchEvents(dataCallback, params)
30 | const dispatch = () => {}
31 |
32 | t.equal(typeof thunk, 'function', 'fetchEvents returns a thunk function immediately')
33 |
34 | thunk(dispatch)
35 | })
36 |
37 | test('fetchEvents() dispatch', t => {
38 | const params = {
39 | type: 'courses',
40 | id: 'MI-RUB',
41 | date: '2015-09-09',
42 | }
43 | let dispatch = spy()
44 |
45 | const responseData = {
46 | events: ['event', 'event'],
47 | linkNames: {
48 | teachers: [
49 | {
50 | id: 'vomackar',
51 | name: {
52 | cs: 'Karel Vomáčka',
53 | en: 'Carl Vomacka',
54 | },
55 | },
56 | ],
57 | },
58 | }
59 |
60 | let callback = (passedParams, cb) => cb(null, responseData)
61 | let thunk = actions.fetchEvents(callback, params)
62 | thunk(dispatch)
63 |
64 | const expectedCalls = 2
65 | t.equal(dispatch.callCount, expectedCalls, `dispatch has been called ${expectedCalls} times`)
66 |
67 | t.test('fetchEvents() first dispatch', st => {
68 | const expectedArg = {type: EVENTS_LOAD_STARTED}
69 | const [actualArg] = dispatch.firstCall.args
70 |
71 | st.deepEqual(actualArg, expectedArg, 'dispatches an EVENTS_LOAD_STARTED')
72 | st.end()
73 | })
74 |
75 | t.test('fetchEvents() second dispatch', st => {
76 | const expectedLinkNames = {
77 | cs: {
78 | teachers: {
79 | vomackar: 'Karel Vomáčka',
80 | },
81 | courses: {},
82 | exceptions: {},
83 | },
84 | en: {
85 | teachers: {
86 | vomackar: 'Carl Vomacka',
87 | },
88 | courses: {},
89 | exceptions: {},
90 | },
91 | }
92 |
93 | const {events} = responseData
94 | const expectedArg = {type: EVENTS_LOAD_COMPLETED,
95 | payload: {events, linkNames: expectedLinkNames}}
96 | const [actualArg] = dispatch.secondCall.args
97 |
98 | st.deepEqual(actualArg, expectedArg, 'dispatches an EVENTS_LOAD_COMPLETED')
99 | st.end()
100 | })
101 |
102 | t.test('fetchEvents() failed events load', st => {
103 | const error = new Error('error message')
104 | error.type = 'generic'
105 |
106 | callback = (passedParams, cb) => cb(error)
107 | dispatch = spy()
108 | thunk = actions.fetchEvents(callback, params)
109 |
110 | thunk(dispatch)
111 |
112 | const [actualArg] = dispatch.secondCall.args
113 | const expectedArg = {
114 | type: EVENTS_LOAD_FAILED,
115 | payload: error,
116 | }
117 |
118 | st.deepEqual(actualArg, expectedArg, 'dispatches EVENTS_LOAD_FAILED as second')
119 | st.end()
120 | })
121 | })
122 |
123 | test('hideDataError()', t => {
124 | const expected = {type: DATA_ERROR_HIDE, payload: {}}
125 | const actual = actions.hideDataError()
126 |
127 | t.deepEqual(actual, expected, 'dispatches DATA_ERROR_HIDE with empty payload')
128 | t.end()
129 | })
130 |
--------------------------------------------------------------------------------
/test/actions/filterActions.test.js:
--------------------------------------------------------------------------------
1 | import test from 'blue-tape'
2 | import { DISPLAY_FILTERS_CHANGE } from '../../src/constants/actionTypes'
3 | import * as actions from '../../src/actions/filterActions'
4 |
5 | test('changeDisplayFilters()', t => {
6 | const payload = { laboratory: false, assessment: true }
7 | const result = actions.changeDisplayFilters(payload)
8 |
9 | t.equal(result.type, DISPLAY_FILTERS_CHANGE, 'matches the action type')
10 | t.deepEqual(result.displayFilters, payload, 'matches the given filters')
11 | t.end()
12 | })
13 |
--------------------------------------------------------------------------------
/test/actions/linkActions.test.js:
--------------------------------------------------------------------------------
1 | import test from 'blue-tape'
2 | import * as actions from '../../src/actions/linkActions'
3 |
4 | const CALENDAR = {type: 'course', id: 'MI-RUB', date: '2015-10-11'}
5 |
6 | test('calendarUrl()', t => {
7 | t.test('calendarUrl() with all parameters', st => {
8 | st.plan(1)
9 | const expected = 'courses/MI-RUB?date=2015-10-11'
10 | const actual = actions.calendarUrl(CALENDAR)
11 |
12 | st.equal(actual, expected, 'generates URL for singular entity type')
13 | })
14 |
15 | t.test('calendarUrl() without date', st => {
16 | st.plan(1)
17 | const calendar = {
18 | type: 'person',
19 | id: 'vomackar',
20 | date: null,
21 | }
22 | const expected = 'people/vomackar'
23 | const actual = actions.calendarUrl(calendar)
24 |
25 | st.equal(actual, expected, 'generates URL without a date')
26 | })
27 |
28 | t.test('calendarUrl() for people/me', st => {
29 | st.plan(1)
30 | const expected = ''
31 | const calendar = {
32 | type: 'person',
33 | id: 'me',
34 | date: null,
35 | }
36 | const actual = actions.calendarUrl(calendar)
37 |
38 | st.equal(actual, expected, 'generates empty URL')
39 | })
40 |
41 | t.end()
42 | })
43 |
44 | test('changeCalendar()', t => {
45 | const actual = actions.changeCalendar(CALENDAR)
46 | const expectedPayload = {
47 | args: [null, 'courses/MI-RUB?date=2015-10-11'],
48 | method: 'pushState',
49 | }
50 | t.deepEqual(actual.payload, expectedPayload)
51 | t.end()
52 | })
53 |
--------------------------------------------------------------------------------
/test/actions/searchActions.test.js:
--------------------------------------------------------------------------------
1 | import test from 'blue-tape'
2 | import { spy } from 'sinon'
3 | import { SEARCH_REQUEST, SEARCH_RESPONSE } from '../../src/constants/actionTypes'
4 | import * as actions from '../../src/actions/searchActions'
5 |
6 | test('fetchSearchResults() executes a given callback with query', t => {
7 | const query = 'my search query'
8 |
9 | const searchCallback = (actualQuery, cb) => {
10 | t.equal(actualQuery, query, 'callback receives a passed query')
11 | t.equal(typeof cb, 'function', 'callback receives a callback for response')
12 | t.end()
13 | }
14 |
15 | const thunk = actions.fetchSearchResults(searchCallback, query)
16 | const dispatch = () => {}
17 |
18 | t.equal(typeof thunk, 'function', 'fetchSearchResults returns a thunk function immediately')
19 |
20 | thunk(dispatch)
21 | })
22 |
23 | test('fetchSearchResults() dispatch', t => {
24 | const query = 'search query'
25 | const results = ['resultA', 'resultB']
26 | // faux callback passed from outside application
27 | const callback = (query, cb) => cb(results)
28 |
29 | let dispatch = spy()
30 | let thunk = actions.fetchSearchResults(callback, query)
31 | thunk(dispatch)
32 |
33 | const expectedCalls = 2
34 | t.equal(dispatch.callCount, expectedCalls, `dispatch has been called ${expectedCalls} times`)
35 |
36 | t.test('fetchSearchResults() first dispatch', st => {
37 | const expectedArg = {type: SEARCH_REQUEST, payload: {query}}
38 | const [actualArg] = dispatch.firstCall.args
39 |
40 | st.deepEqual(actualArg, expectedArg, 'dispatches a SEARCH_REQUEST with query')
41 | st.end()
42 | })
43 |
44 | t.test('fetchSearchResults() second dispatch', st => {
45 | const expectedArg = {type: SEARCH_RESPONSE, payload: {results}}
46 | const [actualArg] = dispatch.secondCall.args
47 |
48 | st.deepEqual(actualArg, expectedArg, 'dispatches an SEARCH_RESPONSE with results')
49 | st.end()
50 | })
51 |
52 | t.test('fetchSearchResults() with empty query', st => {
53 | dispatch = spy()
54 | thunk = actions.fetchSearchResults(callback, '')
55 | thunk(dispatch)
56 | st.equal(dispatch.callCount, 1, 'dispatch is called only once')
57 |
58 | const expectedArg = {type: SEARCH_RESPONSE, payload: {results: []}}
59 | const [actualArg] = dispatch.firstCall.args
60 |
61 | st.deepEqual(actualArg, expectedArg, 'returns SEARCH_RESPONSE with empty results')
62 | st.end()
63 | })
64 | })
65 |
--------------------------------------------------------------------------------
/test/actions/settingsActions.test.js:
--------------------------------------------------------------------------------
1 | import test from 'blue-tape'
2 | import { SETTINGS_CHANGE } from '../../src/constants/actionTypes'
3 | import * as actions from '../../src/actions/settingsActions'
4 |
5 | function expectedVal (payload) {
6 | return {type: SETTINGS_CHANGE, settings: payload}
7 | }
8 |
9 | test('changeSettings', (t) => {
10 | const payload = {locale: 'cs', eventsColors: true}
11 | const result = actions.changeSettings(payload)
12 | t.deepEqual(result, expectedVal(payload))
13 | t.end()
14 | })
15 |
16 | test('setLocale', (t) => {
17 | t.deepEqual(actions.setLocale('cs'), expectedVal({locale: 'cs'}))
18 | t.end()
19 | })
20 |
21 | test('setEventsColors', (t) => {
22 | t.deepEqual(actions.setEventsColors(true), expectedVal({eventsColors: true}))
23 | t.end()
24 | })
25 |
26 | test('setFullWeek', (t) => {
27 | t.deepEqual(actions.setFullWeek(true), expectedVal({fullWeek: true}))
28 | t.end()
29 | })
30 |
31 | test('setFacultyGrid', (t) => {
32 | t.deepEqual(actions.setFacultyGrid(true), expectedVal({facultyGrid: true}))
33 | t.end()
34 | })
35 |
36 | test('setLayout', (t) => {
37 | t.deepEqual(actions.setLayout('horizontal'), expectedVal({layout: 'horizontal'}))
38 | t.end()
39 | })
40 |
--------------------------------------------------------------------------------
/test/actions/uiActions.test.js:
--------------------------------------------------------------------------------
1 | import test from 'blue-tape'
2 | import { SIDEBAR_DISPLAY, EVENT_DISPLAY } from '../../src/constants/actionTypes'
3 | import * as actions from '../../src/actions/uiActions'
4 |
5 | test('displaySidebar()', t => {
6 | const expected = {type: SIDEBAR_DISPLAY, payload: {sidebar: 'search'}}
7 | const actual = actions.displaySidebar('search')
8 | t.deepEqual(actual, expected)
9 | t.end()
10 | })
11 |
12 | test('displayEvent()', t => {
13 | const expected = {type: EVENT_DISPLAY, payload: {eventId: 154}}
14 | const actual = actions.displayEvent(154)
15 | t.deepEqual(actual, expected)
16 | t.end()
17 | })
18 |
--------------------------------------------------------------------------------
/test/actions/userActions.test.js:
--------------------------------------------------------------------------------
1 | import test from 'blue-tape'
2 | import { spy } from 'sinon'
3 | import { USER_LOAD_STARTED, USER_LOAD_COMPLETED } from '../../src/constants/actionTypes'
4 | import * as actions from '../../src/actions/userActions'
5 |
6 | test('fetchUserData() dispatches USER_LOAD_STARTED', t => {
7 | const thunk = actions.fetchUserData()
8 | const dispatch = spy()
9 |
10 | t.equal(typeof thunk, 'function', 'fetchUserData returns a thunk function immediately')
11 |
12 | thunk(dispatch)
13 |
14 | const expectedCalls = 2
15 | t.equal(dispatch.callCount, expectedCalls, `dispatch has been called ${expectedCalls} times`)
16 |
17 | t.test('fetchUserData() first dispatch', st => {
18 | const expectedArg = {type: USER_LOAD_STARTED}
19 | const [actualArg] = dispatch.firstCall.args
20 |
21 | st.deepEqual(actualArg, expectedArg, 'dispatches an USER_LOAD_STARTED')
22 | st.end()
23 | })
24 |
25 | t.test('fetchEvents() second dispatch', st => {
26 | const [actualArg] = dispatch.secondCall.args
27 |
28 | st.equal(actualArg.type, USER_LOAD_COMPLETED, 'dispatches USER_LOAD_COMPLETED')
29 | st.equal(typeof actualArg.payload.publicAccessToken, 'string',
30 | 'dispatches payload with publicAccessToken')
31 | st.equal(typeof actualArg.payload.id, 'string', 'dispatches payload with user id')
32 | st.equal(typeof actualArg.payload.name, 'string', 'dispatches payload with user name')
33 | st.end()
34 | })
35 | })
36 |
37 | test('logoutUser()', t => {
38 | const thunk = actions.logoutUser()
39 |
40 | global.location = { href: 'index.js' }
41 |
42 | t.equal(typeof thunk, 'function', 'logoutUser returns a thunk function immediately')
43 |
44 | thunk()
45 |
46 | t.equal(global.location.href, 'landing.html', 'redirects user to landing.html after logging out')
47 |
48 | t.end()
49 | })
50 |
--------------------------------------------------------------------------------
/test/client.test.js:
--------------------------------------------------------------------------------
1 | import test from 'blue-tape'
2 | import { forEach, toPairs } from 'ramda'
3 | import * as c from '../src/client'
4 |
5 | test('browserLanguage', t => {
6 | const testStrings = {
7 | cs: ['cs', 'cs-CZ', 'CS_Czech'],
8 | en: ['en', 'en-US', 'American English', 'foo', false, null],
9 | }
10 |
11 | forEach(([expectedLang, strings]) => {
12 | forEach(testString => {
13 | const actual = c.browserLanguage(testString)
14 | t.equal(actual, expectedLang, `returns ${expectedLang} on browser lang ${testString}`)
15 | }, strings)
16 | }, toPairs(testStrings))
17 |
18 | t.end()
19 | })
20 |
--------------------------------------------------------------------------------
/test/dataManipulation.test.js:
--------------------------------------------------------------------------------
1 | import test from 'blue-tape'
2 | import * as manip from '../src/dataManipulation'
3 |
4 | test('invertLinkNames()', t => {
5 | const origData = {
6 | teachers: [
7 | {
8 | id: 'vomackar',
9 | name: {
10 | cs: 'Karel Vomáčka',
11 | en: 'Carl Vomacka',
12 | },
13 | },
14 | ],
15 | courses: [
16 | {
17 | id: 'MI-RUB',
18 | name: {
19 | cs: 'Programování v Ruby',
20 | en: 'Programming in Ruby',
21 | },
22 | },
23 | ],
24 | exceptions: [
25 | {
26 | id: 42,
27 | name: 'An Exception',
28 | },
29 | ],
30 | }
31 |
32 | const expected = {
33 | cs: {
34 | teachers: {
35 | vomackar: 'Karel Vomáčka',
36 | },
37 | courses: {
38 | 'MI-RUB': 'Programování v Ruby',
39 | },
40 | exceptions: {
41 | 42: 'An Exception',
42 | },
43 | },
44 | en: {
45 | teachers: {
46 | vomackar: 'Carl Vomacka',
47 | },
48 | courses: {
49 | 'MI-RUB': 'Programming in Ruby',
50 | },
51 | exceptions: {
52 | 42: 'An Exception',
53 | },
54 | },
55 | }
56 |
57 | const actual = manip.invertLinkNames(origData)
58 |
59 | t.deepEqual(actual, expected, 'inverts link names by locale')
60 | t.end()
61 | })
62 |
--------------------------------------------------------------------------------
/test/date.test.js:
--------------------------------------------------------------------------------
1 | import test from 'blue-tape'
2 | import tk from 'timekeeper'
3 | import * as date from '../src/date'
4 |
5 | test('now', (t) => {
6 | tk.freeze()
7 | const currentDate = new Date()
8 | t.assert(Object.isFrozen(date.now()), 'returns a frozen object')
9 | t.deepEqual(date.now().toString(), currentDate.toString(), 'generates current date')
10 | tk.reset()
11 | t.end()
12 | })
13 |
14 | test('isoDate', (t) => {
15 | const expectedDate = '2015-09-03'
16 | const today = new Date(`${expectedDate}T00:00:00+0200`)
17 |
18 | t.equal(date.isoDate(today), expectedDate, 'generates ISO date in a correct timezone')
19 | t.end()
20 | })
21 |
22 | test('weekRange', (t) => {
23 | const today = new Date('2015-09-03')
24 | const start = new Date('2015-08-31T00:00:00+0200').toString()
25 | const end = new Date('2015-09-06T23:59:59+0200').toString()
26 | const expectedRange = [start, end]
27 |
28 | const actualRange = date.weekRange(today).map(d => d.toString())
29 |
30 | t.deepEqual(actualRange, expectedRange)
31 |
32 | t.end()
33 | })
34 |
35 | test('workWeekRange', (t) => {
36 | const today = new Date('2015-09-03')
37 | const start = new Date('2015-08-31T00:00:00+0200').toString()
38 | const end = new Date('2015-09-04T23:59:59+0200').toString()
39 | const expectedRange = [start, end]
40 |
41 | const actualRange = date.workWeekRange(today).map(d => d.toString())
42 |
43 | t.deepEqual(actualRange, expectedRange)
44 |
45 | t.end()
46 | })
47 |
48 | test('isoWeekRange', (t) => {
49 | const today = new Date('2015-09-03')
50 |
51 | const start = '2015-08-31'
52 | const end = '2015-09-06'
53 | const expectedRange = [start, end]
54 |
55 | const actualRange = date.isoWeekRange(today)
56 |
57 | t.deepEqual(actualRange, expectedRange)
58 |
59 | t.end()
60 | })
61 |
62 | test('shiftDate', t => {
63 | const today = new Date('2015-09-03')
64 | const shiftDateForToday = date.shiftDate(today)
65 | t.equal(typeof shiftDateForToday, 'function', 'is curried')
66 |
67 | const testParams = [
68 | {args: ['week', 1], expected: '2015-09-10'},
69 | {args: ['weeks', -2], expected: '2015-08-20'},
70 | {args: ['months', +3], expected: '2015-12-03'},
71 | {args: ['months', -12], expected: '2014-09-03'},
72 | ]
73 |
74 | testParams.forEach(({args, expected}) => {
75 | const actual = shiftDateForToday(...args)
76 | t.equal(date.isoDate(actual), expected, `shifts by ${args[1]} ${args[0]}`)
77 | })
78 |
79 | t.end()
80 | })
81 |
82 | test('weekdayNum()', t => {
83 | const day = new Date('2015-09-06') // Sunday
84 |
85 | const expected = 6
86 | const actual = date.weekdayNum(day)
87 |
88 | t.equal(actual, expected, 'returns a 0-indexed ISO weekday number')
89 | t.end()
90 | })
91 |
92 | test('compareDate', t => {
93 | const a = new Date('2015-09-06')
94 | const b = new Date('2015-09-07')
95 |
96 | t.equal(date.compareDate(a, b), -1, 'returns -1 if first var is smaller')
97 | t.equal(date.compareDate(b, a), 1, 'returns +1 if first var is smaller')
98 |
99 | const c = new Date('2015-09-06T14:00')
100 | const d = new Date('2015-09-06T10:00')
101 | t.equal(date.compareDate(c, d), 0, 'returns 0 if the date is the same, regardless of date')
102 | t.end()
103 | })
104 |
105 | test('withinDates', t => {
106 | const a = new Date('2015-09-08')
107 | const b = new Date('2015-09-09')
108 | const c = new Date('2015-09-10')
109 |
110 | t.equal(date.withinDates(a, b, c), false, 'returns false if last arg is outside of interval')
111 | t.equal(date.withinDates(a, c, b), true, 'returns true if last arg is within interval')
112 | t.equal(date.withinDates(a, b, a), true, 'is inclusive with edge dates')
113 | t.end()
114 | })
115 |
116 | test('strToDate', t => {
117 | const str = '2015-09-03'
118 | const expected = new Date(str).toString()
119 | const actual = date.strToDate(str).toString()
120 |
121 | t.equal(actual, expected)
122 | t.end()
123 | })
124 |
125 | test('setDateToZeroTime', t => {
126 | const d = new Date('2015-09-03 15:00:00')
127 |
128 | t.deepEqual(date.setDateToZeroTime(d), new Date('2015-09-03 00:00:00'), 'sets date to zero time')
129 | t.notDeepEqual(date.setDateToZeroTime(d), d, 'test if function returns new object')
130 | t.end()
131 | })
132 |
133 | test('weekStartDate', t => {
134 | const expected = new Date('2015-12-14 00:00:00')
135 |
136 | ;['2015-12-16', '2015-12-14', '2015-12-20'].forEach(d => {
137 | const actual = date.weekStartDate(new Date(`${d} 00:00:00`))
138 | t.deepEqual(actual, expected, `returns 2015-12-14 as start of the week for ${d}`)
139 | })
140 |
141 | t.end()
142 | })
143 |
--------------------------------------------------------------------------------
/test/helpers/routerState.js:
--------------------------------------------------------------------------------
1 | import {merge} from 'ramda'
2 |
3 | const DEFAULT_PARAMS = {
4 | calendarId: 'MI-RUB',
5 | calendarType: 'courses',
6 | date: '2015-10-12',
7 | }
8 |
9 | export default function (params = {}) {
10 | const {calendarId, calendarType, date} = merge(DEFAULT_PARAMS, params)
11 |
12 | return {
13 | router: {
14 | params: {
15 | calendarId,
16 | calendarType,
17 | },
18 | location: {
19 | query: {
20 | date,
21 | },
22 | },
23 | },
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/test/reducers/clientReducer.test.js:
--------------------------------------------------------------------------------
1 | import test from 'blue-tape'
2 | import { CLIENT_CHANGE } from '../../src/constants/actionTypes'
3 | import reducer from '../../src/reducers/clientReducer'
4 | import { SMALL_SCREEN, MEDIUM_SCREEN } from '../../src/constants/screenSizes'
5 |
6 | test('client reducer initial state', t => {
7 | const actual = reducer(undefined, {type: null})
8 | const expected = {
9 | screenSize: SMALL_SCREEN,
10 | }
11 | t.deepEqual(actual, expected, 'small screen is considered by default')
12 | t.end()
13 | })
14 |
15 | test('client reducer CLIENT_CHANGE action', t => {
16 | const state = {
17 | screenSize: SMALL_SCREEN,
18 | }
19 | const expected = {
20 | screenSize: MEDIUM_SCREEN,
21 | }
22 | const actual = reducer(state, {type: CLIENT_CHANGE, payload: {screenSize: MEDIUM_SCREEN}})
23 | t.deepEqual(actual, expected, 'small screen is considered by default')
24 | t.end()
25 | })
26 |
27 |
--------------------------------------------------------------------------------
/test/reducers/dataReducer.test.js:
--------------------------------------------------------------------------------
1 | import test from 'blue-tape'
2 | import {
3 | EVENTS_LOAD_STARTED, EVENTS_LOAD_COMPLETED, EVENTS_LOAD_FAILED, DATA_ERROR_HIDE,
4 | } from '../../src/constants/actionTypes'
5 | import reducer from '../../src/reducers/dataReducer'
6 |
7 | const INITIAL_STATE = {
8 | waiting: true,
9 | linkNames: {
10 | cs: { courses: {}, teachers: {}, exceptions: {} },
11 | en: { courses: {}, teachers: {}, exceptions: {} },
12 | },
13 | events: [],
14 | errorVisible: false,
15 | error: {
16 | type: null,
17 | message: null,
18 | },
19 | }
20 |
21 | test('data reducer initial state', t => {
22 | const actual = reducer(undefined, {type: null})
23 | const expectedLinkNames = {
24 | cs: { courses: {}, teachers: {}, exceptions: {} },
25 | en: { courses: {}, teachers: {}, exceptions: {} },
26 | }
27 | t.equal(actual.waiting, true, 'waiting is initially true')
28 | t.deepEqual(actual.events, [], 'events are empty')
29 | t.deepEqual(actual.linkNames, expectedLinkNames, 'initialises linkNames structure')
30 | t.deepEqual(actual.errorVisible, false, 'initialises with error hidden')
31 | t.deepEqual(actual.error, {type: null, message: null}, 'initialises with null error')
32 | t.end()
33 | })
34 |
35 | test('data reducer EVENTS_LOAD_STARTED action', t => {
36 | const state = {...INITIAL_STATE, waiting: false}
37 |
38 | const expected = {...state, waiting: true}
39 | const actual = reducer(state, {type: EVENTS_LOAD_STARTED})
40 | t.deepEqual(actual, expected, 'sets waiting to true')
41 | t.end()
42 | })
43 |
44 | test('data reducer EVENTS_LOAD_COMPLETED action', t => {
45 | const state = {
46 | ...INITIAL_STATE,
47 | waiting: true,
48 | errorVisible: true,
49 | }
50 |
51 | const events = [
52 | 'some event',
53 | 'other event',
54 | ]
55 | const linkNames = {
56 | cs: { teachers: {skocdop: 'Petr Skočdopole'}, exceptions: {}, courses: {} },
57 | }
58 |
59 | const expected = {
60 | ...state,
61 | waiting: false,
62 | errorVisible: false,
63 | events,
64 | linkNames,
65 | }
66 | const actual = reducer(state, {type: EVENTS_LOAD_COMPLETED, payload: {events, linkNames}})
67 |
68 | t.deepEqual(actual, expected, 'emits passed payload, sets waiting and errorVisible to false')
69 | t.end()
70 | })
71 |
72 | test('data reducer EVENTS_LOAD_FAILED action', t => {
73 | const state = {
74 | ...INITIAL_STATE,
75 | waiting: true,
76 | errorVisible: false,
77 | }
78 |
79 | const payload = new Error('Something failed')
80 | payload.type = 'generic'
81 |
82 | const actual = reducer(state, {type: EVENTS_LOAD_FAILED, payload})
83 |
84 | t.equal(actual.waiting, false, 'sets waiting to false')
85 | t.equal(actual.errorVisible, true, 'sets error visibility to true')
86 | t.equal(actual.error.type, 'generic', 'stores error type into state')
87 | t.equal(actual.error.message,
88 | 'Error: Something failed', 'sets error message by serialising the error')
89 | t.end()
90 | })
91 |
92 | test('data reducer DATA_ERROR_HIDE action', t => {
93 | const expected = {errorVisible: false, error: {message: 'loremipsum', type: 'generic'}}
94 | const actual = reducer({errorVisible: true, error: {message: 'loremipsum', type: 'generic'}},
95 | {type: DATA_ERROR_HIDE, payload: {}})
96 |
97 | t.deepEqual(actual, expected, 'hides an error message')
98 | t.end()
99 | })
100 |
--------------------------------------------------------------------------------
/test/reducers/index.test.js:
--------------------------------------------------------------------------------
1 | import test from 'blue-tape'
2 | import { type } from 'ramda'
3 | import * as actionTypes from '../../src/constants/actionTypes'
4 | import reducer from '../../src/reducers'
5 |
6 | test('Initial state', t => {
7 | const result = reducer(undefined, {type: 'FAUX_ACTION'})
8 | t.is(type(result.settings), 'Object')
9 | t.is(type(result.displayFilters), 'Object')
10 | t.is(type(result.data), 'Object')
11 | t.is(type(result.ui), 'Object')
12 | t.is(type(result.search), 'Object')
13 | t.is(type(result.semester), 'Object')
14 | t.is(type(result.client), 'Object')
15 | t.is(type(result.user), 'Object')
16 | t.end()
17 | })
18 |
19 | test('displayFilters change', t => {
20 | const action = {
21 | type: actionTypes.DISPLAY_FILTERS_CHANGE,
22 | displayFilters: {
23 | laboratory: false,
24 | other: true,
25 | },
26 | }
27 |
28 | const actual = reducer(undefined, action).displayFilters
29 |
30 | t.equal(actual.laboratory, false, 'changes given filter to false (laboratory)')
31 | t.equal(actual.tutorial, true, 'returns also other filters (tutorial)')
32 | t.equal(actual.other, true, 'keeps the original filter state (other)')
33 |
34 | t.end()
35 | })
36 |
--------------------------------------------------------------------------------
/test/reducers/searchReducer.test.js:
--------------------------------------------------------------------------------
1 | import test from 'blue-tape'
2 | import { SEARCH_REQUEST, SEARCH_RESPONSE } from '../../src/constants/actionTypes'
3 | import reducer from '../../src/reducers/searchReducer'
4 |
5 | test('search reducer initial state', t => {
6 | const actual = reducer(undefined, {type: null})
7 | const expected = {
8 | waiting: false,
9 | query: '',
10 | results: [],
11 | }
12 | t.deepEqual(actual, expected,
13 | 'initialises empty structure with no results, query and not waiting')
14 |
15 | t.end()
16 | })
17 |
18 | test('search reducer SEARCH_REQUEST action', t => {
19 | const state = {waiting: false, query: 'meh', results: ['lorem', 'ipsum']}
20 | const expected = {waiting: true, query: 'my query', results: []}
21 | const actual = reducer(state, {type: SEARCH_REQUEST, payload: {query: 'my query'}})
22 |
23 | t.deepEqual(actual, expected, 'sets waiting to true, propagates the query and empties results')
24 | t.end()
25 | })
26 |
27 | test('search reducer SEARCH_RESPONSE action', t => {
28 | const state = {waiting: true, query: 'query', results: []}
29 |
30 | const results = [
31 | 'resultA',
32 | 'resultB',
33 | ]
34 |
35 | const expected = {
36 | waiting: false,
37 | query: 'query',
38 | results,
39 | }
40 | const actual = reducer(state, {type: SEARCH_RESPONSE, payload: {results}})
41 |
42 | t.deepEqual(actual, expected, 'emits passed results, keeps query and sets waiting to false')
43 | t.end()
44 | })
45 |
--------------------------------------------------------------------------------
/test/reducers/semesterReducer.test.js:
--------------------------------------------------------------------------------
1 | import test from 'blue-tape'
2 | import { SEMESTER_LOAD_COMPLETED } from '../../src/constants/actionTypes'
3 | import reducer from '../../src/reducers/semesterReducer'
4 |
5 | const INITIAL_STATE = {
6 | season: 'winter',
7 | grid: {
8 | // Fallback data for FIT
9 | starts: 7.5,
10 | ends: 21.5,
11 | lessonDuration: 0.875,
12 | },
13 | periods: [],
14 | valid: false,
15 | }
16 |
17 | test('semester reducer initial state', t => {
18 | const actual = reducer(undefined, {type: null})
19 | const expected = INITIAL_STATE
20 |
21 | t.deepEqual(actual, expected, 'fallbacks to [winter] and FIT grid')
22 | t.end()
23 | })
24 |
25 | test('semester reducer SEMESTER_LOAD_COMPLETED action', t => {
26 | const state = {
27 | ...INITIAL_STATE,
28 | }
29 |
30 | const payload = {
31 | season: 'summer',
32 | grid: {
33 | starts: 7.0,
34 | ends: 21.0,
35 | lessonDuration: 0.75,
36 | },
37 | }
38 | const expected = {
39 | ...state,
40 | ...payload,
41 | }
42 | const actual = reducer(state, {type: SEMESTER_LOAD_COMPLETED, payload})
43 |
44 | t.deepEqual(actual, expected, 'emits passed payload')
45 | t.end()
46 | })
47 |
--------------------------------------------------------------------------------
/test/reducers/uiReducer.test.js:
--------------------------------------------------------------------------------
1 | import test from 'blue-tape'
2 | import { SIDEBAR_DISPLAY, EVENT_DISPLAY } from '../../src/constants/actionTypes'
3 | import reducer from '../../src/reducers/uiReducer'
4 |
5 | test('UI reducer initial state', t => {
6 | const actual = reducer(undefined, {type: null})
7 | const expected = {
8 | sidebar: null,
9 | eventId: null,
10 | }
11 | t.deepEqual(actual, expected, 'no sidebar or event detail is open')
12 | t.end()
13 | })
14 |
15 | test('ui reducer SIDEBAR_DISPLAY action', t => {
16 | const expected = {sidebar: 'search', eventId: null}
17 | const actual = reducer(undefined, {type: SIDEBAR_DISPLAY, payload: {sidebar: 'search'}})
18 | t.deepEqual(actual, expected, 'sets sidebar to a given state')
19 | t.end()
20 |
21 | t.test('ui reducer SIDEBAR_DISPLAY action with the same sidebar already opened', st => {
22 | const expected = null
23 | const actual = reducer({sidebar: 'search', eventId: null},
24 | {type: SIDEBAR_DISPLAY, payload: {sidebar: 'search'}})
25 |
26 | st.deepEqual(actual.sidebar, expected, 'resets sidebar to null')
27 | st.end()
28 | })
29 | })
30 |
31 | test('UI reducer EVENT_DISPLAY action', t => {
32 | const expected = {sidebar: 'search', eventId: 42}
33 | const actual = reducer({sidebar: 'search', eventId: null},
34 | {type: EVENT_DISPLAY, payload: {eventId: 42}})
35 | t.deepEqual(actual, expected, 'sets event to a given ID')
36 | t.end()
37 | })
38 |
--------------------------------------------------------------------------------
/test/reducers/userReducer.test.js:
--------------------------------------------------------------------------------
1 | import test from 'blue-tape'
2 | import { USER_LOAD_STARTED, USER_LOAD_COMPLETED } from '../../src/constants/actionTypes'
3 | import reducer from '../../src/reducers/userReducer'
4 |
5 | test('user reducer initial state', t => {
6 | const actual = reducer(undefined, {type: null})
7 | const expected = {
8 | isFetching: false,
9 | publicAccessToken: null,
10 | id: null,
11 | name: null,
12 | }
13 |
14 | t.deepEqual(actual, expected, 'is not fetching and has an empty token')
15 | t.end()
16 | })
17 |
18 | test('user reducer on USER_LOAD_STARTED action', t => {
19 | const state = {
20 | isFetching: false,
21 | publicAccessToken: null,
22 | }
23 | const expected = {
24 | isFetching: true,
25 | publicAccessToken: null,
26 | }
27 | const actual = reducer(state, {type: USER_LOAD_STARTED})
28 |
29 | t.deepEqual(actual, expected, 'changes isFetching to true')
30 | t.end()
31 | })
32 |
33 | test('user reducer on USER_LOAD_COMPLETED action', t => {
34 | const state = {
35 | isFetching: true,
36 | publicAccessToken: null,
37 | id: null,
38 | name: null,
39 | }
40 | const expected = {
41 | isFetching: false,
42 | publicAccessToken: 'asd123',
43 | id: 'loremi',
44 | name: 'Lorem Ipsum',
45 | }
46 | const payload = {
47 | publicAccessToken: 'asd123',
48 | id: 'loremi',
49 | name: 'Lorem Ipsum',
50 | }
51 | const actual = reducer(state, {type: USER_LOAD_COMPLETED, payload})
52 |
53 | t.deepEqual(actual, expected, 'changes isFetching to true')
54 | t.end()
55 | })
56 |
--------------------------------------------------------------------------------
/test/screen.test.js:
--------------------------------------------------------------------------------
1 | import test from 'blue-tape'
2 | import * as screen from '../src/screen'
3 | import * as screenSizes from '../src/constants/screenSizes.js'
4 |
5 | test('isScreenSmall', t => {
6 | const screenSize = screenSizes.SMALL_SCREEN
7 | t.assert(screen.isScreenSmall(screenSize), 'returns true when screen is small')
8 | t.end()
9 | })
10 |
11 | test('isScreenMedium', t => {
12 | const screenSize = screenSizes.MEDIUM_SCREEN
13 | t.assert(screen.isScreenMedium(screenSize), 'returns true when screen is medium')
14 | t.end()
15 | })
16 |
17 | test('isScreenLarge', t => {
18 | const screenSize = screenSizes.LARGE_SCREEN
19 | t.assert(screen.isScreenLarge(screenSize), 'returns true when screen is large')
20 | t.end()
21 | })
22 |
23 | /* -- */
24 |
25 | test('not isScreenSmall', t => {
26 | const screenSize = screenSizes.LARGE_SCREEN
27 | t.assert(!screen.isScreenSmall(screenSize), 'returns true when screen is not small')
28 | t.end()
29 | })
30 |
31 | test('not isScreenMedium', t => {
32 | const screenSize = screenSizes.SMALL_SCREEN
33 | t.assert(!screen.isScreenMedium(screenSize), 'returns true when screen is not medium')
34 | t.end()
35 | })
36 |
37 | test('not isScreenLarge', t => {
38 | const screenSize = screenSizes.MEDIUM_SCREEN
39 | t.assert(!screen.isScreenLarge(screenSize, 'returns true when screen is not large'))
40 | t.end()
41 | })
42 |
43 | /* -- */
44 |
45 | test('isScreenMediumAndUp', t => {
46 |
47 | t.assert(!screen.isScreenMediumAndUp(screenSizes.SMALL_SCREEN),
48 | 'returns false when screen is small')
49 |
50 | t.assert(screen.isScreenMediumAndUp(screenSizes.MEDIUM_SCREEN),
51 | 'returns true when screen is medium')
52 |
53 | t.assert(screen.isScreenMediumAndUp(screenSizes.LARGE_SCREEN),
54 | 'returns true when screen is large')
55 |
56 | t.end()
57 | })
58 |
59 | /* -- */
60 |
61 | test('classByScreenSize', t => {
62 | const smallClass = 'class-for-small'
63 | const mediumClass = 'class-for-medium'
64 | const largeClass = 'class-for-large'
65 |
66 | const mappingArray = [
67 | smallClass, mediumClass, largeClass,
68 | ]
69 |
70 | const mappingArrayAssoc = {
71 | small: smallClass,
72 | medium: mediumClass,
73 | large: largeClass,
74 | }
75 |
76 | t.equal(screen.classByScreenSize(screenSizes.SMALL_SCREEN, mappingArray), smallClass,
77 | 'returns class for small on small screen')
78 |
79 | t.equal(screen.classByScreenSize(screenSizes.MEDIUM_SCREEN, mappingArray), mediumClass,
80 | 'returns class for medium on medium screen')
81 |
82 | t.equal(screen.classByScreenSize(screenSizes.LARGE_SCREEN, mappingArray), largeClass,
83 | 'returns class for large on large screen')
84 |
85 | t.equal(screen.classByScreenSize(screenSizes.SMALL_SCREEN, mappingArrayAssoc), smallClass,
86 | 'returns class for small on small screen (with assoc array)')
87 |
88 | t.equal(screen.classByScreenSize(screenSizes.MEDIUM_SCREEN, mappingArrayAssoc), mediumClass,
89 | 'returns class for medium on medium screen (with assoc array)')
90 |
91 | t.equal(screen.classByScreenSize(screenSizes.LARGE_SCREEN, mappingArrayAssoc), largeClass,
92 | 'returns class for large on large screen (with assoc array)')
93 |
94 | t.end()
95 | })
96 |
--------------------------------------------------------------------------------
/test/selectors/routerSelector.test.js:
--------------------------------------------------------------------------------
1 | import test from 'blue-tape'
2 | import * as sel from '../../src/selectors/routerSelector'
3 | import timekeeper from 'timekeeper'
4 | import routerState from '../helpers/routerState'
5 |
6 | const STATE = routerState()
7 |
8 | const FIXED_DATE = '2015-12-13'
9 |
10 | test('calendarId()', t => {
11 | t.equal(sel.calendarId(STATE), 'MI-RUB',
12 | 'extracts calendarId from router')
13 |
14 | t.equal(sel.calendarId(undefined), 'me',
15 | 'returns a fallback calendarId for undefined router state')
16 |
17 | t.end()
18 | })
19 |
20 | test('calendarType()', t => {
21 | t.equal(sel.calendarType(STATE), 'courses',
22 | 'extracts calendarType from router')
23 |
24 | t.equal(sel.calendarType(undefined), 'people',
25 | 'returns a fallback calendarType for undefined router state')
26 |
27 | t.end()
28 | })
29 |
30 | test('viewDate()', t => {
31 | t.equal(sel.viewDate(STATE), '2015-10-12', 'extracts date from query parameter')
32 |
33 | const d = Date.parse(FIXED_DATE)
34 | timekeeper.freeze(d)
35 |
36 | t.equal(sel.viewDate(undefined), FIXED_DATE,
37 | 'returns a fallback value for undefined date query parameter')
38 |
39 | timekeeper.reset()
40 | t.end()
41 | })
42 |
43 | test('calendar()', t => {
44 | const expected = {
45 | id: 'MI-RUB',
46 | type: 'courses',
47 | date: '2015-10-12',
48 | }
49 |
50 | const actual = sel.calendar(STATE)
51 |
52 | t.deepEqual(actual, expected, 'returns all known params from route')
53 | t.end()
54 | })
55 |
--------------------------------------------------------------------------------
/test/semester.test.js:
--------------------------------------------------------------------------------
1 | import test from 'blue-tape'
2 | import { spy } from 'sinon'
3 | import { fmoment } from '../src/date'
4 | import * as s from '../src/semester'
5 |
6 | test('currentSemester()', t => {
7 | const semesters = [
8 | {
9 | id: '18000-B142',
10 | startsOn: fmoment('2014-10-01'),
11 | endsOn: fmoment('2015-02-15'),
12 | },
13 | {
14 | id: '18000-B151',
15 | startsOn: fmoment('2015-02-16'),
16 | endsOn: fmoment('2015-09-21'),
17 | },
18 | ]
19 |
20 | const examples = [
21 | ['2015-03-01', '18000-B151'],
22 | ['2015-02-16', '18000-B151'],
23 | ['2015-02-15', '18000-B142'],
24 | ]
25 |
26 | examples.forEach(([day, expectedId]) => {
27 | const actual = s.findSemester(semesters, day)
28 |
29 | t.equal(actual.id, expectedId, `day ${day} is within semester ${expectedId}`)
30 | })
31 |
32 | t.end()
33 | })
34 |
35 | test('semesterSeason()', t => {
36 | t.equal(s.semesterSeason('B142'), 'summer', 'semester ending with 2 is summer')
37 | t.equal(s.semesterSeason('B151'), 'winter', 'semester ending with 1 is winter')
38 | t.end()
39 | })
40 |
41 | test('semesterYears()', t => {
42 | t.deepEqual(s.semesterYears('B142'), [2014, 2015],
43 | '?14? is a semester in 2014/2015 academic year')
44 |
45 | t.deepEqual(s.semesterYears('B151'), [2015, 2016],
46 | '?15? is a semester in 2015/2016 academic year')
47 |
48 | t.end()
49 | })
50 |
51 | test('convertRawSemester()', t => {
52 |
53 | const periods = [{
54 | type: 'teaching',
55 | startsOn: fmoment('2015-10-05'),
56 | endsOn: fmoment('2015-10-11'),
57 | firstWeekParity: 'even',
58 | }]
59 |
60 | const original = {
61 | id: '18000-B142',
62 | semester: 'B142',
63 | faculty: 18000,
64 | startsOn: fmoment('2015-02-16'),
65 | endsOn: fmoment('2015-09-21'),
66 | hourDuration: 45,
67 | breakDuration: 15,
68 | dayStartsAtHour: 7.5,
69 | dayEndsAtHour: 21.25,
70 | periods,
71 | }
72 |
73 | const expected = {
74 | id: original.id,
75 | startsOn: fmoment('2015-02-16'),
76 | endsOn: fmoment('2015-09-21'),
77 | season: 'summer',
78 | years: [2014, 2015],
79 | grid: {
80 | starts: 7.5,
81 | ends: 21.25,
82 | lessonDuration: 0.875,
83 | },
84 | weeks: {
85 | 2388: {
86 | weekstamp: 2388,
87 | types: ['teaching'],
88 | parity: 'even',
89 | teachingWeek: 1,
90 | periods,
91 | },
92 | },
93 | valid: true,
94 | }
95 |
96 | const actual = s.convertRawSemester(original)
97 | t.deepEqual(actual, expected, 'converts given data to match the expected state')
98 | t.end()
99 | })
100 |
101 | test('dateInSemester()', t => {
102 | const semester = {
103 | startsOn: fmoment('2015-02-16'),
104 | endsOn: fmoment('2015-09-21'),
105 | }
106 |
107 | const dateIn = new Date('2015-03-01')
108 | const dateOut = new Date('2015-10-01')
109 |
110 | t.equal(s.dateInSemester(semester, dateIn), true,
111 | 'returns true for date within the semester')
112 |
113 | t.equal(s.dateInSemester(semester, dateOut), false,
114 | 'returns false for date outside of the semester')
115 |
116 | t.end()
117 | })
118 |
119 | test('semesterName()', t => {
120 | const dispatch = spy()
121 |
122 | // faux counterpart
123 | const translate = (key, options) => {
124 | dispatch(key, options)
125 | return 'translated-string'
126 | }
127 |
128 | const semester = {
129 | season: 'winter',
130 | valid: true,
131 | years: [2015, 2016],
132 | }
133 | s.semesterName(translate, semester)
134 |
135 | const semester2 = {
136 | season: 'summer',
137 | valid: true,
138 | years: [2016, 2017],
139 | }
140 | s.semesterName(translate, semester2)
141 |
142 | const invsemester = {
143 | season: 'summer',
144 | valid: false,
145 | years: [2016, 2017],
146 | }
147 |
148 | const emptysemester = { }
149 |
150 | t.deepEqual(dispatch.firstCall.args, ['winter_sem', {year: '2015/16'}],
151 | 'correctly recognize winter sem. 15/16')
152 |
153 | t.deepEqual(dispatch.lastCall.args, ['summer_sem', {year: '2016/17'}],
154 | 'correctly recognize summer sem. 16/17')
155 |
156 | t.equal(s.semesterName(translate, semester), 'translated-string',
157 | 'returns translated string from counterpart')
158 |
159 | t.equal(s.semesterName(translate, invsemester), null,
160 | 'returns null on invalid semesters')
161 |
162 | t.equal(s.semesterName(translate, emptysemester), null,
163 | 'returns null on semesters with missing data')
164 |
165 | t.end()
166 | })
167 |
--------------------------------------------------------------------------------
/test/semesterWeeks.test.js:
--------------------------------------------------------------------------------
1 | import test from 'blue-tape'
2 | import R from 'ramda'
3 | import { fmoment } from '../src/date'
4 | import * as sp from '../src/semesterWeeks'
5 |
6 | const omitPeriods = R.map(R.omit('periods'))
7 | const pickPeriods = R.map(R.pick('periods'))
8 |
9 | test('semesterPeriodsToWeeks()', t => {
10 |
11 | const periods = [
12 | // type , startsOn , endsOn , weekParity, dayOverride, irregular weekstamp
13 | ['teaching', '2015-12-03', '2015-12-20', 'odd' , undefined , false], // 2396-2398
14 | ['teaching', '2015-12-21', '2015-12-21', 'even' , 'wednesday', true ], // 2399
15 | ['teaching', '2015-12-22', '2015-12-22', 'odd' , 'tuesday' , true ], // 2399
16 | ['holiday' , '2015-12-23', '2016-01-03', undefined , undefined , false], // 2399-2400
17 | ['teaching', '2016-01-04', '2016-01-06', 'even' , undefined , false], // 2401
18 | ['exams' , '2016-01-07', '2016-01-15', undefined , undefined , false], // 2402
19 | ].map(column => ({
20 | type: column[0],
21 | startsOn: fmoment(column[1]),
22 | endsOn: fmoment(column[2]),
23 | firstWeekParity: column[3],
24 | firstDayOverride: column[4],
25 | irregular: column[5],
26 | }))
27 |
28 | const expected = R.pipe(
29 | R.always([
30 | //weekstamp, types , parity , teachWeek, periods
31 | [2396 , ['teaching'] , 'odd' , 1 , [0] ],
32 | [2397 , ['teaching'] , 'even' , 2 , [0] ],
33 | [2398 , ['teaching'] , 'odd' , 3 , [0] ],
34 | [2399 , ['holiday'] , undefined, undefined, [1, 2, 3]],
35 | [2400 , ['holiday'] , undefined, undefined, [3] ],
36 | [2401 , ['teaching', 'exams'], 'even' , 4 , [4, 5] ],
37 | [2402 , ['exams'] , undefined, undefined, [5] ],
38 | ]),
39 | R.map(column => ({
40 | weekstamp: column[0],
41 | types: column[1],
42 | parity: column[2],
43 | teachingWeek: column[3],
44 | periods: column[4].map(idx => periods[idx]),
45 | })),
46 | R.indexBy(o => o.weekstamp)
47 | )()
48 |
49 | const actual = sp.semesterPeriodsToWeeks(periods)
50 |
51 | // Omit periods to make diff more readable.
52 | t.deepEqual(omitPeriods(actual), omitPeriods(expected),
53 | 'converts periods to a map of weekstamp to a week object')
54 |
55 | t.deepEqual(pickPeriods(actual), pickPeriods(expected),
56 | 'week objects contains array of periods')
57 |
58 | t.end()
59 | })
60 |
--------------------------------------------------------------------------------
/test/time.test.js:
--------------------------------------------------------------------------------
1 | import test from 'blue-tape'
2 | import * as time from '../src/time'
3 |
4 | test('convertSecondsToTime()', t => {
5 | [//seconds, h, m, s]
6 | [0, 0, 0, 0],
7 | [1, 0, 0, 1],
8 | [60, 0, 1, 0],
9 | [65, 0, 1, 5],
10 | [3600, 1, 0, 0],
11 | [3601, 1, 0, 1],
12 | ].forEach(([seconds, h, m, s]) => {
13 | t.deepEqual(
14 | time.convertSecondsToTime(seconds), {h, m, s}, `converts correctly ${seconds}s to time`
15 | )
16 | })
17 |
18 | t.end()
19 | })
20 |
--------------------------------------------------------------------------------
/test/utils.test.js:
--------------------------------------------------------------------------------
1 | import test from 'blue-tape'
2 | import R from 'ramda'
3 | import * as util from '../src/utils'
4 |
5 | test('reduceBy()', t => {
6 |
7 | const input = [
8 | { name: 'Abby', score: 84 },
9 | { name: 'Brad', score: 73 },
10 | { name: 'Chris', score: 89 },
11 | { name: 'Dianne', score: 99 },
12 | { name: 'Eddy', score: 58 },
13 | { name: 'Hannah', score: 78 },
14 | { name: 'Irene', score: 85 },
15 | ]
16 | const expected = {
17 | A: ['Dianne'],
18 | B: ['Abby', 'Chris', 'Irene'],
19 | C: ['Brad', 'Hannah'],
20 | F: ['Eddy'],
21 | }
22 |
23 | const grade = (n) => (n < 65) ? 'F' : (n < 70) ? 'D' : (n < 80) ? 'C' : (n < 90) ? 'B' : 'A'
24 | const byGrade = R.pipe(R.prop('score'), grade)
25 | const collectNames = (acc, stud) => acc.concat(stud.name)
26 |
27 | t.deepEqual(util.reduceBy(byGrade, collectNames, [], input), expected,
28 | 'splits the list into groups according to the grouping function')
29 |
30 | t.deepEqual(util.reduceBy(byGrade)(collectNames, [])(input), expected,
31 | 'is curried')
32 |
33 | t.deepEqual(util.reduceBy(byGrade, collectNames, [], []), {},
34 | 'returns an empty object if given an empty array')
35 |
36 | t.end()
37 | })
38 |
39 | test('renameKeys()', t => {
40 |
41 | const keysMap = { title: 'name', type: 'kind', foo: 'bar' }
42 | const input = { title: 'Elisia', age: 22, type: 'human' }
43 | const expected = { name: 'Elisia', age: 22, kind: 'human' }
44 |
45 | const inputClone = { ...input }
46 |
47 | t.deepEqual(util.renameKeys(keysMap, input), expected,
48 | 'renames keys according to the given keysMap')
49 |
50 | t.deepEqual(input, inputClone, 'does not mutate given object')
51 |
52 | t.deepEqual(util.renameKeys(keysMap)(input), expected, 'is curried')
53 |
54 | t.end()
55 | })
56 |
--------------------------------------------------------------------------------
/test/utils/safeExpandingDirection.test.js:
--------------------------------------------------------------------------------
1 | import test from 'blue-tape'
2 | import safeExpandingDirection from '../../src/utils/safeExpandingDirection'
3 | import R from 'ramda'
4 |
5 | test('safeExpandingDirection()', t => {
6 | const fakeWindow = { innerWidth: 800, innerHeight: 600 }
7 |
8 | const DEFAULT = '@default'
9 | const isDefault = R.equals(DEFAULT)
10 |
11 | const testParams = [
12 | {
13 | rect: {x: 400, y: 300, width: 200, height: 100},
14 | expected: { bottom: DEFAULT, right: DEFAULT },
15 | desc: "returns defaults when element doesn't overflow",
16 | },
17 | {
18 | rect: {x: 700, y: 300, width: 200, height: 100},
19 | expected: { bottom: DEFAULT, right: false },
20 | desc: 'returns default-right when element overflows on the right',
21 | },
22 | {
23 | rect: {x: 400, y: 50, width: 200, height: 100},
24 | expected: { bottom: true, right: DEFAULT },
25 | desc: 'returns bottom-default when element overflows on the top',
26 | },
27 | {
28 | rect: {x: 199, y: 501, width: 200, height: 100},
29 | expected: { bottom: false, right: true },
30 | desc: 'returns top-right when element overflows on the bottom and left',
31 | },
32 | {
33 | rect: {x: 601, y: -300, width: 200, height: 100},
34 | expected: { bottom: true, right: false },
35 | desc: 'returns bottom-left when element overflows on the top and right',
36 | },
37 | {
38 | rect: {x: 400, y: 300, width: 401, height: 301},
39 | expected: { bottom: DEFAULT, right: DEFAULT },
40 | desc: 'returns defaults when element overflows by 1 px in all directions',
41 | },
42 | ]
43 |
44 | testParams.forEach(({ rect, expected, desc }) => {
45 | ;[ [true, false], [false, true] ].forEach(([right, bottom]) => {
46 |
47 | const defaultDir = { right, bottom }
48 | const args = [
49 | [rect.x, rect.y],
50 | [rect.width, rect.height],
51 | fakeWindow,
52 | defaultDir,
53 | ]
54 |
55 | const _expected = R.evolve({
56 | right: R.when(isDefault, () => defaultDir.right),
57 | bottom: R.when(isDefault, () => defaultDir.bottom),
58 | }, expected)
59 |
60 | t.deepEqual(R.apply(safeExpandingDirection, args), _expected, desc)
61 | })
62 | })
63 |
64 | t.end()
65 | })
66 |
--------------------------------------------------------------------------------
/test/utils/suitClassName.test.js:
--------------------------------------------------------------------------------
1 | import test from 'blue-tape'
2 | import R from 'ramda'
3 | import suitClassName from '../../src/utils/suitClassName'
4 |
5 | test('suitClassName()', t => {
6 | ;[
7 | {
8 | args: ['ComponentName', 'descendent', ['modifier'], { states: true }],
9 | expected: 'ComponentName-descendent ComponentName-descendent--modifier is-states',
10 | },
11 | {
12 | args: ['AwesomeComponent', 'heading', [], { blinking: false }],
13 | expected: 'AwesomeComponent-heading',
14 | },
15 | {
16 | args: ['AwesomeComponent', 'footer', ['darker'], { blinking: true }],
17 | expected: 'AwesomeComponent-footer AwesomeComponent-footer--darker is-blinking',
18 | },
19 | {
20 | args: ['MenuBar', null, ['lighter'], { blinking: true, visible: true }],
21 | expected: 'MenuBar MenuBar--lighter is-blinking is-visible',
22 | },
23 | {
24 | args: ['Selector', 'text', [], { hoverable: true, highlighted: false }],
25 | expected: 'Selector-text is-hoverable',
26 | },
27 | {
28 | args: ['Car', 'roof', ['shiny', 'large'], { opened: true }],
29 | expected: 'Car-roof Car-roof--shiny Car-roof--large is-opened',
30 | },
31 | {
32 | args: ['Selector'],
33 | expected: 'Selector',
34 | },
35 | ].forEach(({args, expected}) => {
36 | t.equal(R.apply(suitClassName, args), expected, 'returns ' + expected)
37 | })
38 |
39 | t.end()
40 | })
41 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | /* eslint-env node */
2 | /* eslint no-var:0 */
3 | var path = require('path')
4 | var webpack = require('webpack')
5 | var ExtractTextPlugin = require('extract-text-webpack-plugin')
6 | require('dotenv').load()
7 |
8 | var config = require('./webpack/config')
9 |
10 | var srcPath = path.resolve('./src')
11 |
12 | var proxyMatch = config.SIRIUS_PROXY_PATH + '/**'
13 |
14 | var definePlugin = new webpack.DefinePlugin({
15 | // Remember this will get replaced with literal contents of string, so we need extra quotes
16 | 'process.env.NODE_ENV': JSON.stringify(config.NODE_ENV),
17 | 'process.env.FITTABLE_SOURCE': JSON.stringify(config.FITTABLE_SOURCE),
18 | 'process.env.SIRIUS_PROXY_PATH': JSON.stringify(config.SIRIUS_PROXY_PATH),
19 | 'process.env.SENTRY_DSN': JSON.stringify(config.SENTRY_DSN),
20 | })
21 |
22 | var sassLoader = '!sass?' +
23 | 'includePaths[]=' +
24 | (path.resolve(__dirname, './node_modules/foundation-sites/scss')) +
25 | '&' +
26 | 'includePaths[]=' +
27 | (path.resolve(__dirname, './node_modules'))
28 |
29 | // webpack-dev-server extension
30 | var ENV_COOKIES = {
31 | /* eslint camelcase:0 */
32 | oauth_access_token: process.env.OAUTH_ACCESS_TOKEN,
33 | oauth_username: process.env.OAUTH_USERNAME,
34 | }
35 |
36 | function setCookiesMiddleware (req, res, next) {
37 | for (var name in ENV_COOKIES) {
38 | var value = ENV_COOKIES[name]
39 | res.cookie(name, value, {httpOnly: false})
40 | }
41 | next()
42 | }
43 |
44 | function rewriteUrl (replacePath) {
45 | return function (req, opt) {
46 | var queryIdx = req.url.indexOf('?')
47 | var query = queryIdx >= 0 ? req.url.substr(queryIdx) : ''
48 |
49 | req.url = req.path.replace(opt.path, replacePath) + query
50 | req.headers['Authorization'] = 'Bearer ' + ENV_COOKIES.oauth_access_token
51 |
52 | console.log('proxying:', req.originalUrl, '->', req.url)
53 | }
54 | }
55 |
56 | module.exports = {
57 | entry: {
58 | js: srcPath + '/app.js',
59 | css: srcPath + '/stylesheets/fittable.scss',
60 | },
61 |
62 | output: {
63 | filename: 'fittable.js',
64 | path: path.resolve('./dist'),
65 | },
66 |
67 | plugins: [
68 | definePlugin,
69 | new ExtractTextPlugin('fittable.css', {
70 | allChunks: true,
71 | }),
72 | ],
73 |
74 | module: {
75 | loaders: [
76 | {
77 | test: /\.jsx?$/,
78 | loader: 'babel',
79 | include: srcPath,
80 | },
81 | {
82 | test: /\.json$/,
83 | loader: 'json',
84 | },
85 | {
86 | test: /\.scss$/,
87 | loader: ExtractTextPlugin.extract('style-loader', 'css!autoprefixer' + sassLoader + ''),
88 | },
89 | {
90 | test: /\.(jpg|png)$/,
91 | loader: 'file',
92 | },
93 | ],
94 | },
95 | resolve: {
96 | alias: {
97 | 'react': path.join(__dirname, 'node_modules', 'react'),
98 | },
99 | extensions: ['', '.jsx', '.js'],
100 | },
101 |
102 | stats: {
103 | colors: true,
104 | },
105 |
106 | devServer: {
107 | hot: true,
108 | inline: true,
109 | lazy: false,
110 | historyApiFallback: true,
111 | contentBase: 'dist/',
112 | setup: function (app) {
113 | app.use(setCookiesMiddleware)
114 | },
115 | proxy: [
116 | {
117 | path: proxyMatch,
118 | rewrite: rewriteUrl('$1'),
119 | target: config.SIRIUS_UPSTREAM_URL,
120 | changeOrigin: true, // set proper Host of the target
121 | secure: false, // do not validate certificates
122 | },
123 | ],
124 | },
125 |
126 | }
127 |
--------------------------------------------------------------------------------
/webpack.production.config.js:
--------------------------------------------------------------------------------
1 | /* eslint-env node */
2 | /* eslint no-var:0 */
3 |
4 | process.env.NODE_ENV = 'production'
5 | process.env.FITTABLE_SOURCE = 'sirius'
6 |
7 | var config = require('./webpack.config')
8 |
9 | module.exports = config
10 |
--------------------------------------------------------------------------------
/webpack/config.js:
--------------------------------------------------------------------------------
1 | /* eslint-env node */
2 | /* eslint no-var:0 */
3 |
4 | // Useful for React & friends
5 | var NODE_ENV = process.env.NODE_ENV || 'development'
6 |
7 | // Hide Sirius URL behind SIRIUS_TARGET
8 | // currently useful only for proxy
9 | var SIRIUS_UPSTREAM_URL = 'https://sirius.fit.cvut.cz/staging/api/v1/'
10 | if (process.env.SIRIUS_TARGET === 'production') {
11 | SIRIUS_UPSTREAM_URL = 'https://sirius.fit.cvut.cz/api/v1/'
12 | }
13 |
14 | // Which data source should be used by Fittable?
15 | // Passed to client code.
16 | var FITTABLE_SOURCE = process.env.FITTABLE_SOURCE || 'faux'
17 |
18 | // Path to which bind proxy to and where client will send requests.
19 | // Passed to client code.
20 | // XXX: You need to keep this in sync with server configuration!
21 | var SIRIUS_PROXY_PATH = process.env.SIRIUS_PROXY_PATH || '/api/sirius'
22 |
23 | // Data Source Name with *public* client key for Sentry.
24 | var SENTRY_DSN = process.env.SENTRY_DSN
25 |
26 | module.exports = {
27 | NODE_ENV: NODE_ENV,
28 | FITTABLE_SOURCE: FITTABLE_SOURCE,
29 | SIRIUS_UPSTREAM_URL: SIRIUS_UPSTREAM_URL,
30 | SIRIUS_PROXY_PATH: SIRIUS_PROXY_PATH,
31 | SENTRY_DSN: SENTRY_DSN,
32 | }
33 |
--------------------------------------------------------------------------------