├── .editorconfig
├── .env
├── .github
├── dependabot.yaml
└── workflows
│ ├── README.md
│ ├── autodeploy.yml
│ └── ci.yml
├── .gitignore
├── .prettierrc
├── .python-version
├── LICENSE.txt
├── README.md
├── deploy
├── README.md
├── cron_scripts
│ ├── cronic
│ ├── crontab
│ └── update_latest.sh
└── web_scripts
│ └── notify_build.py
├── eslint.config.js
├── package-lock.json
├── package.json
├── public
├── .htaccess
├── f22.json
├── f23.json
├── f24.json
├── hydrant.png
├── i25.json
├── latestTerm.json
├── s23.json
├── s24.json
└── s25.json
├── pylintrc
├── react-router.config.ts
├── requirements.txt
├── scrapers
├── README.md
├── __init__.py
├── __main__.py
├── catalog.py
├── cim.py
├── fireroad.py
├── math_dept.py
├── overrides.toml.d
│ ├── 11.toml
│ ├── 12.toml
│ ├── 15.toml
│ ├── 21a.toml
│ ├── 21g.toml
│ ├── 21l.toml
│ ├── 21m.toml
│ ├── 4.toml
│ ├── 6.toml
│ ├── cms.toml
│ └── override-schema.json
├── package.py
├── text_mining.py
└── utils.py
├── src
├── assets
│ ├── Lab.gif
│ ├── PartLab.gif
│ ├── calendar-button.png
│ ├── cih.gif
│ ├── cihw.gif
│ ├── fall.gif
│ ├── fuzzAndAnt.png
│ ├── fuzzball.png
│ ├── grad.gif
│ ├── hassA.gif
│ ├── hassE.gif
│ ├── hassH.gif
│ ├── hassS.gif
│ ├── hydraAnt.png
│ ├── iap.gif
│ ├── logo-dark.svg
│ ├── logo.svg
│ ├── nonext.gif
│ ├── repeat.gif
│ ├── rest.gif
│ ├── simple-fuzzball.png
│ ├── spring.gif
│ ├── summer.gif
│ └── under.gif
├── components
│ ├── ActivityButtons.tsx
│ ├── ActivityDescription.tsx
│ ├── Calendar.scss
│ ├── Calendar.tsx
│ ├── ClassTable.scss
│ ├── ClassTable.tsx
│ ├── FeedbackBanner.tsx
│ ├── Footers.tsx
│ ├── Header.tsx
│ ├── MatrixLink.tsx
│ ├── PreregLink.tsx
│ ├── SIPBLogo.tsx
│ ├── ScheduleOption.tsx
│ ├── ScheduleSwitcher.tsx
│ ├── SelectedActivities.tsx
│ ├── TermSwitcher.tsx
│ └── ui
│ │ ├── button.tsx
│ │ ├── checkbox.tsx
│ │ ├── close-button.tsx
│ │ ├── color-mode.tsx
│ │ ├── color-picker.tsx
│ │ ├── dialog.tsx
│ │ ├── field.tsx
│ │ ├── input-group.tsx
│ │ ├── link-button.tsx
│ │ ├── menu.tsx
│ │ ├── provider.tsx
│ │ ├── radio.tsx
│ │ ├── select.tsx
│ │ └── tooltip.tsx
├── entry.client.tsx
├── lib
│ ├── activity.ts
│ ├── calendarSlots.ts
│ ├── class.ts
│ ├── colors.ts
│ ├── dates.ts
│ ├── gapi.ts
│ ├── hydrant.ts
│ ├── rawClass.ts
│ ├── schema.ts
│ ├── state.ts
│ ├── store.ts
│ └── utils.tsx
├── root.tsx
├── routes.ts
├── routes
│ ├── Index.tsx
│ ├── Overrides.tsx
│ └── export.ts
└── vite-env.d.ts
├── tsconfig.json
└── vite.config.ts
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | end_of_line = lf
6 | insert_final_newline = true
7 | trim_trailing_whitespace = true
8 | indent_style = space
9 |
10 | [*.{ts,tsx,js,json}]
11 | indent_size = 2
12 | max_line_length = 80
13 |
14 | [*.py]
15 | indent_size = 4
16 | max_line_length = 88
17 |
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
1 | VITE_GOOGLE_CLIENT_ID=706375540729-sqnnig7o0d0uqmvav0h8nh8aft6l55u3.apps.googleusercontent.com
2 |
--------------------------------------------------------------------------------
/.github/dependabot.yaml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 |
4 | version: 2
5 | updates:
6 | - package-ecosystem: "npm"
7 | directory: "/"
8 | schedule:
9 | interval: "monthly"
10 | - package-ecosystem: "pip"
11 | directory: "/"
12 | schedule:
13 | interval: "monthly"
14 | - package-ecosystem: "github-actions"
15 | directory: "/"
16 | schedule:
17 | interval: "monthly"
18 |
--------------------------------------------------------------------------------
/.github/workflows/README.md:
--------------------------------------------------------------------------------
1 | # Deploy scripts
2 | Scripts in this directory are processed by Github's CI service.
3 |
4 | ## autodeploy.yml
5 | This script automatically deploys a build of Hydrant to Scripts when it receives the `push` Git hook.
6 |
7 | ## ci.yml
8 | This script runs Black (for backend formatting), Prettier (for frontend formatting), and ESLint (for frontend linting) for any pull request or push to the `main` branch.
9 |
--------------------------------------------------------------------------------
/.github/workflows/autodeploy.yml:
--------------------------------------------------------------------------------
1 | name: Autodeploy branch
2 |
3 | on:
4 | push:
5 | branches:
6 | - deploy
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - uses: actions/checkout@v4
14 | - name: Install node
15 | uses: actions/setup-node@v4
16 | with:
17 | node-version: 20
18 | - name: Install node dependencies
19 | run: npm install
20 | - name: Checks types
21 | run: npm run typecheck
22 | - name: Run build
23 | run: npm run build
24 | - name: Archive build artifacts
25 | uses: actions/upload-artifact@v4
26 | with:
27 | name: built-site
28 | path: |
29 | build/client
30 | !build/client/latest.json
31 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | pull_request:
7 | branches: [main]
8 |
9 | env:
10 | node-version: 20.x
11 | python-version: 3.7
12 |
13 | jobs:
14 | black:
15 | name: Black
16 | runs-on: ubuntu-latest
17 | steps:
18 | - uses: actions/checkout@v4
19 | - uses: psf/black@stable
20 | pylint:
21 | name: Pylint
22 | runs-on: ubuntu-22.04
23 | steps:
24 | - uses: actions/checkout@v4
25 | - name: Set up Python 3.7
26 | uses: actions/setup-python@v5
27 | with:
28 | python-version: ${{ env.python-version }}
29 | cache: "pip"
30 | - name: Install dependencies
31 | run: |
32 | python -m pip install --upgrade pip
33 | pip install -r requirements.txt
34 | pip install pylint
35 | - name: Analysing the code with pylint
36 | run: |
37 | pylint $(git ls-files '*.py')
38 | prettier:
39 | name: Prettier
40 | runs-on: ubuntu-latest
41 | steps:
42 | - uses: actions/checkout@v4
43 | - name: Install node
44 | uses: actions/setup-node@v4
45 | with:
46 | node-version: ${{ env.node-version }}
47 | cache: "npm"
48 | - name: Install node dependencies
49 | run: npm install
50 | - name: Run Prettier
51 | run: npm run ci-prettier
52 | lint:
53 | name: Lint + Typecheck
54 | runs-on: ubuntu-latest
55 | steps:
56 | - uses: actions/checkout@v4
57 | - name: Install node
58 | uses: actions/setup-node@v4
59 | with:
60 | node-version: ${{ env.node-version }}
61 | cache: "npm"
62 | - name: Install node dependencies
63 | run: npm install
64 | - name: Check types
65 | run: npm run typecheck
66 | - name: Run ESLint
67 | run: npm run ci-eslint
68 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
26 |
27 | # artifacts
28 | scrapers/catalog.json
29 | scrapers/fireroad-sem.json
30 | scrapers/fireroad-presem.json
31 | scrapers/cim.json
32 | public/latest.json
33 | public/m25.json
34 |
35 | # python
36 | __pycache__
37 |
38 | # deploy script
39 | deploy.sh
40 |
41 | #virtual env
42 | hydrant-venv
43 |
44 | # router
45 | .react-router
46 | build
47 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {}
2 |
--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------
1 | 3.7.7
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 MIT Student Information Processing Board
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Hydrant
2 |
3 | ## Setup
4 |
5 | Install:
6 |
7 | - Python 3, at least Python 3.7.
8 | - Node.js, at least Node.js 20.
9 | - One way to manage Node versions is using [nvm](https://github.com/nvm-sh/nvm).
10 | - [Black](https://black.readthedocs.io/en/stable/index.html), if you plan on contributing changes to the Python backend.
11 |
12 | In the root directory, run:
13 |
14 | - `pip install -r requirements.txt` to install dependencies.
15 | - `npm install` to install dependencies.
16 |
17 | ## Updating
18 |
19 | ### Local development
20 |
21 | There's the frontend, which is the website and the interface. Then there's the backend, or the schedules, which are the files that have information about class schedules.
22 |
23 | To spin up the site, we need two steps:
24 |
25 | (1) We need to update the backend to get the data. Run `python3 -m scrapers`.
26 |
27 | (2) We then can update the frontend, via running `npm run dev`. This will start a developer server. Open a browser tab to [`http://localhost:5173/`](http://localhost:5173/), which will update live as you edit code.
28 |
29 | If this is the **first time** you're spinning up the website, the two steps have to be taken in order: step (1), and then step (2). If not followed, you'll see a blank frontend.
30 |
31 | After the first time, the step order doesn't matter to bring up the site; in fact, backend step (1) can even be skipped -- since you'd already have locally cached data. Though backend commands are still necessary if you'd like to keep the data updated.
32 |
33 | Before making commits, run `black .` (for the backend) and `npm run format` (for the frontend) to ensure that your changes comply with the project's code style. (This will also get checked by CI when you open a pull request.) Both [Black](https://black.readthedocs.io/en/stable/integrations/editors.html) and [Prettier](https://prettier.io/docs/en/editors) have editor integrations as well.
34 |
35 | ### Changing semesters
36 |
37 | Let's say you're updating from e.g. Spring 2023 to Fall 2023.
38 |
39 | First, archive the old semester. Make sure you have updated schedule files. Then run `mv public/latest.json public/s23.json`.
40 |
41 | Then, update the new semester. Open `public/latestTerm.json`, change `urlName` to `m23` (for the "pre-semester" summer 2023) and `f23` (for the semester fall 2023), and update the dates per [Registrar](https://registrar.mit.edu/calendar).
42 |
43 | Next, update the `.gitignore` to ignore `public/m23.json` rather than `public/i23.json`.
44 |
45 | Finally, run the normal update process and commit the results to the repo.
46 |
47 | ### Updating the server
48 |
49 | The server's frontend updates based on the `deploy` branch on GitHub, so any changes pushed there will become live.
50 |
51 | The server's backend is updated through a cron script that runs `update.py` every hour.
52 |
53 | See `deploy/README.md` for more info.
54 |
55 | ## Development notes
56 |
57 | ### Architecture
58 |
59 | _I want to change..._
60 |
61 | - _...the data available to Hydrant._
62 | - The entry point is `scrapers/update.py`.
63 | - This goes through the client loader in `src/routes/Index.tsx`, which looks for the data.
64 | - The exit point is through the constructor of `State` in `src/lib/state.ts`.
65 | - _...the way Hydrant behaves._
66 | - The entry point is `src/lib/state.ts`.
67 | - The exit point is through `src/routes/Index.tsx`, which constructs `hydrant` and adds it to a reusable context.
68 | - _...the way Hydrant looks._
69 | - The entry point is `src/routes/Index.tsx`.
70 | - We use [Chakra UI](https://chakra-ui.com/) as our component library. Avoid writing CSS.
71 | - _...routes available in Hydrant._
72 | - Routes are stored in `src/routes.ts` and can be modified there.
73 | - Run `npm run typecheck` to make sure route types are still ok once you're done
74 |
75 | ### Technologies
76 |
77 | Try to introduce as few technologies as possible to keep this mostly future-proof. If you introduce something, make sure it'll last a few years. Usually one of these is a sign it'll last:
78 |
79 | - some MIT class teaches how to use it
80 | - e.g. web.lab teaches React, 6.102 teaches Typescript
81 | - it's tiny and used in only a small part of the app
82 | - e.g. msgpack-lite is only used for URL encoding, nanoid is only used to make IDs
83 | - it's a big, popular, well-documented project that's been around for several years
84 | - e.g. FullCalendar has been around since 2010, Chakra UI has a large community
85 |
--------------------------------------------------------------------------------
/deploy/README.md:
--------------------------------------------------------------------------------
1 | # Hydrant - deploy steps
2 |
3 | This file, and other contents of this folder, should be mirrored in `/afs/sipb/project/hydrant`. This folder is called our *locker*.
4 |
5 | If you're in the `sipb-hydrant` list, you can login to Athena and do `ssh hydrant@scripts`. You'll sign in to the Scripts account, whose home folder `~` is `/afs/sipb/project/hydrant`.
6 |
7 | Our repo has a copy at `~/hydrant` with symlinks to other places. In particular:
8 |
9 | - `~/README.md` links to `~/hydrant/deploy/README.md`.
10 | - `~/cron_scripts` links to `~/hydrant/deploy/cron_scripts`.
11 | - `~/web_scripts/notify_build.py` links to `~/hydrant/deploy/web_scripts/notify_build.py`.
12 | - Nothing else in `~/web_scripts` should be in the repo.
13 | - Nothing in `~/ci_secrets` should be in the repo.
14 | - But read the `~/ci_secrets/README` file!
15 |
16 | ## Frontend
17 |
18 | Everything on `~/web_scripts` is served to the internet under (https://hydrant.scripts.mit.edu/). The service that does this is called [Scripts](https://scripts.mit.edu/). The link (https://hydrant.mit.edu) points to the subfolder `~/web_scripts/hydrant`, which is where the deployed files are.
19 |
20 | The server's frontend updates based on the `deploy` branch on GitHub, so any changes pushed there will become live. In particular:
21 |
22 | 1. GitHub actions CI triggers (https://github.com/sipb/hydrant/actions). This builds the frontend on the GitHub servers, saving the built directory in an 'artifact'. You can find the artifact by hand at https://github.com/sipb/hydrant/actions/runs/RUN_ID_HERE.
23 |
24 | 2. Once the build is done, CI fires a GitHub webhook (https://github.com/sipb/hydrant/settings/hooks). This one is pointed at (https://hydrant.scripts.mit.edu/report_build.py); hosted at `~/web_scripts/report_build.py` in the locker.
25 |
26 | The webhook does something called a [POST request](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST). You can think of it as running the `~/web_scripts/report_build.py` script and giving it input, except we're giving the input through the internet. The input is taken in through `stdin`, and is encoded with JSON.
27 |
28 | You can look at the output of the webhook in the Github hooks settings: (https://github.com/sipb/hydrant/settings/hooks).
29 |
30 | 3. The script grabs the URL to the 'artifact'* and downloads it. The relevant API docs are on GitHub at (https://docs.github.com/en/rest/actions/artifacts).
31 |
32 | 4. The script unpacks the files to the production directory `~/web_scripts/hydrant`.
33 |
34 | Note that the GitHub token in `ci_secrets` must be [regenerated](https://github.com/settings/personal-access-tokens/) if it's ever invalidated.
35 |
36 | ## Backend
37 |
38 | The entire backend is the `latest.json` file in `~/web_scripts/hydrant/latest.json`.
39 |
40 | We have a [cron job](https://en.wikipedia.org/wiki/Cron) that runs every hour that updates this file. Cron is configured with a crontab file, in `~/cron_scripts/crontab`. There's a line (the one starting with `0 * * * *`) that calls the `~/cron_scripts/update_latest.sh` every hour.
41 |
42 | The `~/cron_scripts/update_latest.sh` pulls the latest version of the `deploy` branch on GitHub to the folder `~/hydrant`. Inside that folder, it then runs the `scrapers/update.py` script. Then it copies the `latest.json` from that folder to `~/web_scripts/hydrant`, where it is served to the internet.
43 |
--------------------------------------------------------------------------------
/deploy/cron_scripts/cronic:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Cronic v3 - cron job report wrapper
4 | # Copyright 2007-2016 Chuck Houpt. No rights reserved, whatsoever.
5 | # Public Domain CC0: http://creativecommons.org/publicdomain/zero/1.0/
6 |
7 | set -eu
8 |
9 | TMP=$(mktemp -d)
10 | OUT=$TMP/cronic.out
11 | ERR=$TMP/cronic.err
12 | TRACE=$TMP/cronic.trace
13 |
14 | set +e
15 | "$@" >$OUT 2>$TRACE
16 | RESULT=$?
17 | set -e
18 |
19 | PATTERN="^${PS4:0:1}\\+${PS4:1}"
20 | if grep -aq "$PATTERN" $TRACE
21 | then
22 | ! grep -av "$PATTERN" $TRACE > $ERR
23 | else
24 | ERR=$TRACE
25 | fi
26 |
27 | if [ $RESULT -ne 0 -o -s "$ERR" ]
28 | then
29 | echo "Cronic detected failure or error output for the command:"
30 | echo "$@"
31 | echo
32 | echo "RESULT CODE: $RESULT"
33 | echo
34 | echo "ERROR OUTPUT:"
35 | cat "$ERR"
36 | echo
37 | echo "STANDARD OUTPUT:"
38 | cat "$OUT"
39 | if [ $TRACE != $ERR ]
40 | then
41 | echo
42 | echo "TRACE-ERROR OUTPUT:"
43 | cat "$TRACE"
44 | fi
45 | fi
46 |
47 | rm -rf "$TMP"
48 |
--------------------------------------------------------------------------------
/deploy/cron_scripts/crontab:
--------------------------------------------------------------------------------
1 | # If you edit this file, make sure to:
2 | # - SSH into our Scripts account,
3 | # - then run "cronload crontab" in the cron_scripts directory.
4 |
5 | # This line sets a reasonable default path
6 | PATH=/mit/hydrant/cron_scripts:/usr/kerberos/sbin:/usr/kerberos/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/X11R6/bin
7 |
8 | # This line mails the STDOUT and STDERR of every cron script to a person
9 | # (can be useful for debugging)
10 | # You can always redirect the output of individual commands to /dev/null
11 | MAILTO="sipb-hydrant@mit.edu"
12 | # If you do not want to receive any mail from cron, use the line below instead
13 | #MAILTO=""
14 |
15 | # Update data pulled from the most recent APIs; then plop it down in production
16 | # This runs every hour, on the 0th minute.
17 | 0 * * * * cronic update_latest.sh
18 |
19 | # See http://en.wikipedia.org/wiki/Cron (or google for crontab) for more info
20 |
--------------------------------------------------------------------------------
/deploy/cron_scripts/update_latest.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -e
4 |
5 | # This script runs the machinery in the given dir to scrape latest.json
6 | # using the Hydrant scrapers, then updates it into the given directory.
7 |
8 | # Hydrant base directory, i.e. the one that has the copy of the repo, with
9 | # all the scripts in it. In the locker, this is ~/hydrant.
10 | REPO_DIR="/afs/sipb.mit.edu/project/hydrant/hydrant"
11 |
12 | # The output directory, i.e. the one that has the folder being served to the
13 | # internet. In the locker, this is ~/web_scripts/hydrant.
14 | OUT_DIR="/afs/sipb.mit.edu/project/hydrant/web_scripts/hydrant"
15 |
16 | cd "$REPO_DIR"
17 |
18 | # -q means quietly; don't report anything in stdout or stderr.
19 | # make sure we're in the right branch:
20 | git checkout -f deploy -q
21 | git pull -q
22 |
23 | # The scripts machine we use has Python 3.7, so use that.
24 | # This updates $OUT_FILE.
25 | python3.7 -m scrapers
26 | OUT_FILE="$REPO_DIR/public/*.json"
27 |
28 | # Copy $OUT_FILE to the output directory, so it can be served to the internet.
29 | cp $OUT_FILE "$OUT_DIR"
30 |
--------------------------------------------------------------------------------
/deploy/web_scripts/notify_build.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | """Accept a web-hook from GitHub telling us about a new built version of Hydrant."""
4 |
5 | import json
6 | import traceback
7 | from hmac import digest
8 | from os import environ, path
9 | from sys import stdin, stdout
10 | from zipfile import ZipFile
11 |
12 | import requests
13 |
14 | LOCKER_DIR = "/afs/sipb.mit.edu/project/hydrant"
15 |
16 | OUTPUT_DIR = path.join(LOCKER_DIR, "web_scripts/hydrant")
17 | ERROR_LOG = path.join(LOCKER_DIR, "error_log")
18 |
19 | CI_SECRETS_DIR = path.join(LOCKER_DIR, "ci_secrets")
20 | HASH_SECRET = path.join(CI_SECRETS_DIR, "hash_secret")
21 | GITHUB_TOKEN = path.join(CI_SECRETS_DIR, "github_token")
22 |
23 |
24 | # pylint: disable=too-many-locals
25 | def main():
26 | """
27 | Fetch the artifact from the GitHub API and extract it into the output directory.
28 | """
29 | # Secret, used for HMAC input validation (so we know GitHub is being real)
30 | with open(HASH_SECRET, encoding="utf-8") as file_hash:
31 | secret = file_hash.read().strip().encode("utf-8")
32 | # API token for GitHub API requests (to get a path to the file).
33 | with open(GITHUB_TOKEN, encoding="utf-8") as file_token:
34 | token = file_token.read().strip()
35 |
36 | # Slurp content and validate with HMAC
37 | body = stdin.read()
38 | hexdigest = "sha256=" + digest(secret, body.encode("utf-8"), "sha256").hex()
39 | if hexdigest != environ.get("HTTP_X_HUB_SIGNATURE_256", ""):
40 | raise ValueError("bad digest")
41 |
42 | # Extract the Run ID for the build
43 | payload = json.loads(body)
44 | if payload.get("action") != "completed":
45 | raise ValueError("not completed")
46 | job_id = payload.get("workflow_job", {}).get("run_id")
47 | if not job_id:
48 | raise ValueError("no job id")
49 |
50 | # Fetch a list of artifacts from the GitHub API
51 | response = requests.get(
52 | f"https://api.github.com/repos/sipb/hydrant/actions/runs/{job_id}/artifacts",
53 | timeout=3,
54 | )
55 | if not response.ok:
56 | raise ValueError("bad artifact fetch response: " + str(response.status_code))
57 | artifact_info = response.json()
58 |
59 | # For each known artifact:
60 | success = False
61 | artifacts = artifact_info.get("artifacts", [])
62 | for artifact in artifacts:
63 | # check that its name is correct,
64 | if artifact.get("name") != "built-site":
65 | continue
66 | url = artifact.get("archive_download_url")
67 | if not url:
68 | continue
69 | # then fetch it.
70 | response = requests.get(
71 | url, headers={"Authorization": ("Bearer " + token)}, timeout=3
72 | )
73 | fname = path.join(LOCKER_DIR, "build_artifact.zip")
74 | with open(fname, "wb") as file_buffer:
75 | for chunk in response.iter_content(chunk_size=4096):
76 | file_buffer.write(chunk)
77 | # Extract into the output directory.
78 | with ZipFile(fname, "r") as zfh:
79 | zfh.extractall(OUTPUT_DIR)
80 | success = True
81 | break
82 |
83 | if success:
84 | return "Fetched artifact successfully"
85 |
86 | artifact_names = ", ".join(a.get("name") for a in artifacts)
87 | return f"Could not find artifact among {len(artifacts)}: {artifact_names}"
88 |
89 |
90 | if __name__ == "__main__":
91 | # Respond to the request, it's only polite.
92 | print("Content-Type: text/plain\r\n\r")
93 | try:
94 | print(main())
95 | # pylint: disable=broad-except
96 | except Exception as e:
97 | print(traceback.format_exc(), file=stdout)
98 | with open(ERROR_LOG, "w", encoding="utf-8") as fe:
99 | print(traceback.format_exc(), file=fe)
100 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from "@eslint/js";
2 | import globals from "globals";
3 | import tseslint from "typescript-eslint";
4 | import eslintConfigPrettier from "eslint-config-prettier";
5 |
6 | import reactHooks from "eslint-plugin-react-hooks";
7 | import reactRefresh from "eslint-plugin-react-refresh";
8 |
9 | import { includeIgnoreFile } from "@eslint/compat";
10 | import { fileURLToPath } from "node:url";
11 |
12 | const gitignorePath = fileURLToPath(new URL(".gitignore", import.meta.url));
13 |
14 | export default tseslint.config(includeIgnoreFile(gitignorePath), {
15 | extends: [
16 | js.configs.recommended,
17 | ...tseslint.configs.strictTypeChecked,
18 | ...tseslint.configs.stylisticTypeChecked,
19 | reactHooks.configs["recommended-latest"],
20 | reactRefresh.configs.vite,
21 | eslintConfigPrettier,
22 | ],
23 | files: ["**/*.{ts,tsx}"],
24 | languageOptions: {
25 | ecmaVersion: 2022,
26 | globals: globals.browser,
27 | parserOptions: {
28 | projectService: true,
29 | tsconfigRootDir: import.meta.dirname,
30 | },
31 | },
32 | rules: {
33 | "@typescript-eslint/no-unused-vars": [
34 | "error",
35 | {
36 | args: "all",
37 | argsIgnorePattern: "^_",
38 | caughtErrors: "all",
39 | caughtErrorsIgnorePattern: "^_",
40 | destructuredArrayIgnorePattern: "^_",
41 | varsIgnorePattern: "^_",
42 | ignoreRestSiblings: true,
43 | },
44 | ],
45 | "@typescript-eslint/consistent-type-exports": "warn",
46 | "@typescript-eslint/consistent-type-imports": "warn",
47 | "@typescript-eslint/switch-exhaustiveness-check": "error",
48 | },
49 | });
50 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "hydrant",
3 | "homepage": ".",
4 | "version": "0.1.0",
5 | "private": true,
6 | "type": "module",
7 | "dependencies": {
8 | "@chakra-ui/react": "^3.20.0",
9 | "@emotion/react": "^11.14.0",
10 | "@fontsource-variable/inter": "^5.2.5",
11 | "@fullcalendar/core": "^6.1.17",
12 | "@fullcalendar/interaction": "^6.1.17",
13 | "@fullcalendar/react": "^6.1.17",
14 | "@fullcalendar/timegrid": "^6.1.17",
15 | "@react-router/node": "^7.6.0",
16 | "@rjsf/chakra-ui": "^6.0.0-beta.10",
17 | "@rjsf/core": "^6.0.0-beta.10",
18 | "@rjsf/utils": "^6.0.0-beta.10",
19 | "@rjsf/validator-ajv8": "^6.0.0-beta.10",
20 | "ag-grid-react": "^33.3.2",
21 | "html-entities": "^2.6.0",
22 | "ical-generator": "^9.0.0",
23 | "isbot": "^5.1.28",
24 | "lucide-react": "^0.511.0",
25 | "msgpackr": "^1.11.4",
26 | "nanoid": "^5.1.5",
27 | "next-themes": "^0.4.6",
28 | "react": "^19.1.0",
29 | "react-dom": "^19.1.0",
30 | "react-icons": "^5.5.0",
31 | "react-router": "^7.6.0",
32 | "react-use": "^17.6.0",
33 | "rrule": "^2.8.1",
34 | "smol-toml": "^1.3.4",
35 | "timezones-ical-library": "^1.10.0"
36 | },
37 | "scripts": {
38 | "dev": "react-router dev",
39 | "typecheck": "react-router typegen && tsc",
40 | "build": "react-router build",
41 | "preview": "vite preview",
42 | "format": "prettier --write '**/*.{ts,tsx}'",
43 | "ci-prettier": "prettier --check '**/*.{ts,tsx,js}'",
44 | "ci-eslint": "eslint ."
45 | },
46 | "engines": {
47 | "node": ">=20.0"
48 | },
49 | "devDependencies": {
50 | "@chakra-ui/cli": "^3.20.0",
51 | "@eslint/compat": "^1.2.9",
52 | "@eslint/js": "^9.27.0",
53 | "@react-router/dev": "^7.6.2",
54 | "@types/node": "^22.15.30",
55 | "@types/react": "^19.1.6",
56 | "@types/react-dom": "19.1.5",
57 | "eslint": "^9.28.0",
58 | "eslint-config-prettier": "^10.1.5",
59 | "eslint-plugin-react-hooks": "^5.2.0",
60 | "eslint-plugin-react-refresh": "^0.4.20",
61 | "globals": "^16.2.0",
62 | "prettier": "^3.5.3",
63 | "sass-embedded": "^1.89.1",
64 | "typescript": "^5.8.3",
65 | "typescript-eslint": "^8.33.1",
66 | "vite": "^6.3.5",
67 | "vite-plugin-checker": "^0.9.3",
68 | "vite-plugin-node-polyfills": "^0.23.0",
69 | "vite-tsconfig-paths": "^5.1.4"
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/public/.htaccess:
--------------------------------------------------------------------------------
1 |
2 |
3 | RewriteEngine On
4 | RewriteBase /
5 | RewriteRule ^index\.html$ - [L]
6 | RewriteCond %{REQUEST_FILENAME} !-f
7 | RewriteCond %{REQUEST_FILENAME} !-d
8 | RewriteCond %{REQUEST_FILENAME} !-l
9 | RewriteRule . /index.html [L]
10 |
11 |
12 |
--------------------------------------------------------------------------------
/public/hydrant.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/public/hydrant.png
--------------------------------------------------------------------------------
/public/latestTerm.json:
--------------------------------------------------------------------------------
1 | {
2 | "preSemester": {
3 | "urlName": "m25",
4 | "startDate": "2025-06-09",
5 | "endDate": "2025-08-15",
6 | "holidayDates": [
7 | "2025-06-19",
8 | "2025-07-04"
9 | ]
10 | },
11 | "semester": {
12 | "urlName": "f25",
13 | "startDate": "2025-09-03",
14 | "h1EndDate": "2025-10-17",
15 | "h2StartDate": "2025-10-20",
16 | "endDate": "2025-12-10",
17 | "mondayScheduleDate": null,
18 | "holidayDates": [
19 | "2025-09-19",
20 | "2025-10-13",
21 | "2025-11-10",
22 | "2025-11-11",
23 | "2025-11-27",
24 | "2025-11-28"
25 | ]
26 | }
27 | }
--------------------------------------------------------------------------------
/pylintrc:
--------------------------------------------------------------------------------
1 | [format]
2 | max-line-length = 88
3 |
--------------------------------------------------------------------------------
/react-router.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "@react-router/dev/config";
2 |
3 | export default {
4 | appDirectory: "src",
5 | ssr: false,
6 | } satisfies Config;
7 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | beautifulsoup4==4.11.1
2 | lxml==4.9.3
3 | requests==2.31.0
4 | tomli>=2.0.1; python_version < "3.11"
5 | nltk>=3.6.5 # skip yanked version; see https://pypi.org/project/nltk/#history
--------------------------------------------------------------------------------
/scrapers/README.md:
--------------------------------------------------------------------------------
1 | ## About ##
2 |
3 | This folder contains several files. The files tracked by git are:
4 |
5 | * `__init__.py`
6 | * `__main__.py`
7 | * `catalog.py`
8 | * `cim.py`
9 | * `fireroad.py`
10 | * `math_dept.py`
11 | * `package.py`
12 | * `update.py`
13 | * `utils.py`
14 | * `README.md` - this very file!
15 | * `.pylintrc`
16 | * `overrides.json` - to override scraped data; currently empty
17 |
18 | The files intentionally left out of git are:
19 |
20 | * `catalog.json`
21 | * `cim.json`
22 | * `fireroad.json`
23 | * `fireroad-presem.json`
24 | * `__pycache__`
25 | * `.DS_Store`
26 |
27 | ## Usage ##
28 |
29 | Run `python3 -m scrapers` from the root directory to execute the code. In production, there is a cron job that runs this every hour.
30 |
31 | This program gets its data from MIT classes from two sources:
32 |
33 | * the official catalog: http://student.mit.edu/catalog/index.cgi
34 | * the Fireroad API: https://fireroad.mit.edu/courses/all?full=true
35 |
36 | It is mainly intended to serve as a data source for the frontend, which is the real deal. This is just the backend.
37 |
38 | ## How it works ##
39 |
40 | `__main__.py` calls four other programs, in this order: `fireroad.py`, `catalog.py`, `cim.py`, `package.py`. Each of these four files has a `run()` function, which is its main entry point to the codebase. Broadly speaking:
41 |
42 | * `fireroad.py` creates `fireroad.json` and `fireroad-presem.json`
43 | * `catalog.py` creates `catalog.json`
44 | * `cim.py` creates `cim.json`
45 | * `package.py` combines these to create `../public/latest.json` and another JSON file under `../public/` that corresponds to IAP or summer. (This is the final product that our frontend ingests.)
46 |
47 | `math_dept.py` is an irregularly run file that helps create override data for courses in the MIT math department (since those are formatted slightly differently). `utils.py` contains a few utility functions and variables, which in turn are used by `fireroad.py` and `package.py`. The file `__init__.py` is empty but we include it anyways for completeness.
48 |
49 | ## Contributing ##
50 |
51 | This folder is actually a subfolder of a larger git repository. If you want to contribute to this repository, submit a pull request to https://github.com/sipb/hydrant and we'll merge it if it looks good.
52 |
53 | Depending on how you work, you might find `pylint` and/or running individual programs one at a time and then playing around with the Python shell to be helpful.
54 |
--------------------------------------------------------------------------------
/scrapers/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/scrapers/__init__.py
--------------------------------------------------------------------------------
/scrapers/__main__.py:
--------------------------------------------------------------------------------
1 | """
2 | This is the entry point. Run `python3 -m scrapers` to test this code.
3 |
4 | In production, there's a cron job that runs this script every hour.
5 |
6 | Functions:
7 | * run()
8 | """
9 |
10 | from .fireroad import run as fireroad_run
11 | from .catalog import run as catalog_run
12 | from .cim import run as cim_run
13 | from .package import run as package_run
14 |
15 |
16 | def run():
17 | """
18 | This function is the entry point. There are no arguments.
19 | """
20 | print("=== Update fireroad data (pre-semester) ===")
21 | fireroad_run(False)
22 | print("=== Update fireroad data (semester) ===")
23 | fireroad_run(True)
24 | print("=== Update catalog data ===")
25 | catalog_run()
26 | print("=== Update CI-M data ===")
27 | cim_run()
28 | print("=== Packaging ===")
29 | package_run()
30 |
31 |
32 | if __name__ == "__main__":
33 | run()
34 |
--------------------------------------------------------------------------------
/scrapers/cim.py:
--------------------------------------------------------------------------------
1 | """
2 | To include CI-M metadata, we scrape the Registrar's website which includes a
3 | list of all CI-M subjects for each course.
4 |
5 | run() scrapes this data and writes it to cim.json, in the format:
6 |
7 | .. code-block:: json
8 | {
9 | "6.1800": {
10 | "cim": [
11 | "6-1, 6-2, 6-3, 6-4, 6-5, 6-P",
12 | "10C",
13 | "18-C",
14 | ]
15 | }
16 | }
17 | """
18 |
19 | from __future__ import annotations
20 |
21 | import json
22 | import os.path
23 | from collections import OrderedDict
24 | from collections.abc import Iterable
25 |
26 |
27 | import requests
28 | from bs4 import BeautifulSoup, Tag
29 |
30 | # pylint: disable=line-too-long
31 | CIM_URL = "https://registrar.mit.edu/registration-academics/academic-requirements/communication-requirement/ci-m-subjects/subject"
32 |
33 |
34 | def get_sections() -> Iterable[Tag]:
35 | """
36 | Scrapes accordion sections from Registrar page that contains lists of CI-M
37 |
38 | Returns:
39 | Iterable[bs4.element.Tag]: The accordion sections that contain lists of CI-M
40 | subjects
41 | """
42 | cim_req = requests.get(
43 | CIM_URL,
44 | timeout=5,
45 | )
46 | soup = BeautifulSoup(cim_req.text, "html.parser")
47 |
48 | return (
49 | item
50 | for item in soup.select("[data-accordion-item]")
51 | if item.select(".ci-m__section")
52 | )
53 |
54 |
55 | def get_courses(section: Tag) -> OrderedDict[str, set[str]]:
56 | """
57 | Extracts the courses contained in a section and their corresponding CI-M
58 | subjects.
59 |
60 | Args:
61 | section (bs4.element.Tag): from get_sections()
62 |
63 | Returns:
64 | OrderedDict[str, set[str]]: A mapping from each course (major) contained
65 | within the given section to the set of subject numbers (classes) that may
66 | satisfy the CI-M requirement for that course number.
67 | """
68 | courses: OrderedDict[str, set[str]] = OrderedDict()
69 | for subsec in section.select(".ci-m__section"):
70 | title = subsec.select_one(".ci-m__section-title").text.strip().replace("*", "") # type: ignore
71 |
72 | # If no title, add to the previous subsection
73 | if title:
74 | subjects: set[str] = set()
75 | else:
76 | title, subjects = courses.popitem()
77 |
78 | subjects |= {
79 | subj.text.strip() for subj in subsec.select(".ci-m__subject-number")
80 | }
81 | courses[title] = subjects
82 | return courses
83 |
84 |
85 | def run() -> None:
86 | """
87 | The main entry point.
88 | """
89 | sections = get_sections()
90 |
91 | # This maps each course number to a set of CI-M subjects for that course
92 | courses: OrderedDict[str, set[str]] = OrderedDict()
93 | for section in sections:
94 | new_courses = get_courses(section)
95 | assert new_courses.keys().isdisjoint(courses.keys())
96 | courses.update(new_courses)
97 |
98 | # This maps each subject to a list of courses for which it is a CI-M
99 | subjects: dict[str, dict[str, list[str]]] = {}
100 | for course in courses:
101 | for subj in courses[course]:
102 | for number in subj.replace("J", "").split("/"):
103 | subjects.setdefault(number, {"cim": []})["cim"].append(course)
104 |
105 | fname = os.path.join(os.path.dirname(__file__), "cim.json")
106 | with open(fname, "w", encoding="utf-8") as cim_file:
107 | json.dump(subjects, cim_file)
108 |
109 |
110 | if __name__ == "__main__":
111 | run()
112 |
--------------------------------------------------------------------------------
/scrapers/math_dept.py:
--------------------------------------------------------------------------------
1 | """
2 | Temporary workaround to the math classes being wrong (2023).
3 | Was used to generate the math overrides in package.py; currently unnecessary.
4 |
5 | Functions:
6 | parse_when(when)
7 | test_parse_when()
8 | parse_many_timeslots(days, times)
9 | make_raw_sections(days, times, room):
10 | make_section_override(timeslots, room)
11 | get_rows()
12 | parse_subject(subject)
13 | parse_row(row)
14 | run()
15 | """
16 |
17 | from __future__ import annotations
18 |
19 | from pprint import pprint
20 | from collections.abc import Iterable, Sequence
21 | from typing import Union
22 | from bs4 import BeautifulSoup, Tag
23 | import requests
24 | from .fireroad import parse_timeslot, parse_section
25 |
26 |
27 | def parse_when(when: str) -> tuple[str, str]:
28 | """
29 | Parses when the class happens.
30 |
31 | Args:
32 | when (str): A string describing when the class happens.
33 |
34 | Returns:
35 | tuple[str, str]: A parsed version of this string.
36 | """
37 | # special casing is good enough (otherwise this could be a for loop)
38 | if when[1].isdigit():
39 | day_times = when[:1], when[1:]
40 | elif when[2].isdigit():
41 | day_times = when[:2], when[2:]
42 | elif when[3].isdigit():
43 | day_times = when[:3], when[3:]
44 | else:
45 | assert False
46 | days, times = day_times
47 | # fireroad.py wants dots instead of colons
48 | times = times.replace(":", ".")
49 | return days, times
50 |
51 |
52 | def test_parse_when() -> None:
53 | """
54 | Test cases for parse_when
55 | """
56 | assert parse_when("F10:30-12") == ("F", "10.30-12")
57 | assert parse_when("MW1") == ("MW", "1")
58 | assert parse_when("MWF11") == ("MWF", "11")
59 |
60 |
61 | def parse_many_timeslots(days: str, times: str) -> Iterable[tuple[int, int]]:
62 | """
63 | Parses many timeslots
64 |
65 | Args:
66 | day (str): A list of days
67 | times (str): The timeslot
68 |
69 | Returns:
70 | Iterable[tuple[int, int]]: All of the parsed timeslots, as a list
71 | """
72 | # parse timeslot wants only one letter
73 | return (parse_timeslot(day, times, False) for day in days)
74 |
75 |
76 | def make_raw_sections(days: str, times: str, room: str) -> str:
77 | """
78 | Formats a raw section
79 |
80 | Args:
81 | room (str): The room
82 | days (str): The days
83 | times (str): The times
84 |
85 | Returns:
86 | str: The room, days, and times, presented as a single string
87 | """
88 | return f"{room}/{days}/0/{times}"
89 |
90 |
91 | def make_section_override(
92 | timeslots: Sequence[Sequence[int]], room: str
93 | ) -> tuple[tuple[Sequence[Sequence[int]], str]]:
94 | """
95 | Makes a section override
96 |
97 | Args:
98 | timeslots (Sequence[Sequence[int]]): The timeslots of the section
99 | room (str): The room
100 |
101 | Returns:
102 | tuple[tuple[Sequence[Sequence[int]], str]]: The section override
103 | """
104 | return ((timeslots, room),)
105 | # lol this is wrong
106 | # return [[section, room] for section in timeslots]
107 |
108 |
109 | def get_rows():
110 | """
111 | Scrapes rows from https://math.mit.edu/academics/classes.html
112 |
113 | Returns:
114 | bs4.element.ResultSet: The rows of the table listing classes
115 | """
116 | response = requests.get("https://math.mit.edu/academics/classes.html", timeout=1)
117 | soup = BeautifulSoup(response.text, features="lxml")
118 | course_list: Tag = soup.find("ul", {"class": "course-list"}) # type: ignore
119 | rows = course_list.findAll("li", recursive=False)
120 | return rows
121 |
122 |
123 | def parse_subject(subject: str) -> list[str]:
124 | """
125 | Parses the subject
126 |
127 | Args:
128 | subject (str): The subject name to parse
129 |
130 | Returns:
131 | list[str]: A clean list of subjects corresponding to that subject.
132 | """
133 | # remove "J" from joint subjects
134 | subject = subject.replace("J", "")
135 |
136 | # special case specific to math, if a slash it means that there
137 | # is an additional graduate subject ending in 1
138 | if " / " in subject:
139 | subject = subject.split(" / ")[0]
140 | subjects = [subject, f"{subject}1"]
141 | else:
142 | subjects = [subject]
143 | assert ["/" not in subject for subject in subjects]
144 |
145 | return subjects
146 |
147 |
148 | def parse_row(
149 | row: Tag,
150 | ) -> dict[str, dict[str, Union[str, tuple[tuple[Sequence[Sequence[int]], str]]]]]:
151 | """
152 | Parses the provided row
153 |
154 | Args:
155 | row (bs4.element.Tag): The row that needs to be parsed.
156 |
157 | Returns:
158 | dict[str, dict[str, Union[str, tuple[tuple[Sequence[Sequence[int]], str]]]]]:
159 | The parsed row
160 | """
161 | result: dict[
162 | str, dict[str, Union[str, tuple[tuple[Sequence[Sequence[int]], str]]]]
163 | ] = {}
164 |
165 | subject: str = row.find("div", {"class": "subject"}).text # type: ignore
166 | subjects = parse_subject(subject)
167 |
168 | where_when: Tag = row.find("div", {"class": "where-when"}) # type: ignore
169 | when, where = where_when.findAll("div", recursive=False)
170 | where = where.text
171 | when = when.text
172 | if ";" in when:
173 | # Don't want to handle special case - calculus, already right
174 | return {}
175 | days, times = parse_when(when)
176 | timeslots = parse_many_timeslots(days, times)
177 | for subject in subjects:
178 | lecture_raw_sections = make_raw_sections(days, times, where)
179 | lecture_sections = make_section_override(list(timeslots), where)
180 | result[subject] = {
181 | "lectureRawSections": lecture_raw_sections,
182 | "lectureSections": lecture_sections,
183 | }
184 | # Make sure the raw thing that I do not comprehend is actually correct
185 | assert parse_section(lecture_raw_sections) == lecture_sections[0]
186 | return result
187 |
188 |
189 | def run() -> (
190 | dict[str, dict[str, Union[str, tuple[tuple[Sequence[Sequence[int]], str]]]]]
191 | ):
192 | """
193 | The main entry point
194 |
195 | Returns:
196 | dict[str, dict[str, Union[str, tuple[tuple[Sequence[Sequence[int]], str]]]]]:
197 | All the schedules
198 | """
199 | rows = get_rows()
200 | overrides: dict[
201 | str, dict[str, Union[str, tuple[tuple[Sequence[Sequence[int]], str]]]]
202 | ] = {}
203 |
204 | for row in rows:
205 | parsed_row = parse_row(row)
206 | overrides.update(parsed_row)
207 |
208 | return overrides
209 |
210 |
211 | if __name__ == "__main__":
212 | test_parse_when()
213 | pprint(run())
214 |
--------------------------------------------------------------------------------
/scrapers/overrides.toml.d/12.toml:
--------------------------------------------------------------------------------
1 | #:schema ./override-schema.json
2 | # Course 12 special subjects (fall 2025)
3 |
4 | ['12.S593']
5 | name = "Special Seminar in EAPS-Exploring Solar System Surfaces (No Spacesuit Required)"
6 | description = "Investigates the diverse surfaces of planetary bodies throughout our Solar System through the lens of remote sensing and robotic exploration. Examines how mission data reveals the formation, evolution, and current state of rocky planets, icy moons, and small bodies. Compares and contrasts geological processes across different planetary environments, from Mercury's extreme temperature variations to the subsurface oceans of Europa and Enceladus. Topics may include impact cratering; volcanism; tectonics; weathering and erosion processes; sedimentary deposits; glacial and periglacial features; and the search for habitable environments. Analyzes datasets of planetary surfaces from past and current missions (Cassini, New Horizons, OSIRIS-REx, Mars rovers, and more) alongside recent scientific literature. Emphasizes critical reading, student-led discussions, and comparative planetology approaches, with a component of hypothesis-driven mission concepts driven by current scientific advances."
7 | inCharge = "G. Stucky de Quay"
8 | level = "G"
9 | isVariableUnits = true
10 |
--------------------------------------------------------------------------------
/scrapers/overrides.toml.d/15.toml:
--------------------------------------------------------------------------------
1 | #:schema ./override-schema.json
2 | # Course 15 special subjects (fall 2025)
3 |
4 | ["15.S03"]
5 | name = "SSIM: AI and Money"
6 | level = "G"
7 | lectureUnits = 3
8 | labUnits = 0
9 | preparationUnits = 6
10 | description = "'AI & Money' examines the evolving impact of artificial inteligence on money & finance. The course helps equip students with critical reasoning skills to understand AI driven innovation in finance.\n\nWe will explore how machine learning, generative AI, and advanced analytics are rdefining customer interactions, investing, payments, risk management, trading, underwriting, and compliance. The course also will touch on real-world applications including cyber risk, fraud detection, and AI supply chain decisions.\n\nThe course will review how AI developments could impact markets, economics, and monetary policy. Students will gain an understanding of these emerging technologies, along with their regulatory frameworks around the globe."
11 | inCharge = "Gary Gensler"
12 |
--------------------------------------------------------------------------------
/scrapers/overrides.toml.d/21a.toml:
--------------------------------------------------------------------------------
1 | #:schema ./override-schema.json
2 | # Course 21A special subjects (fall 2025)
3 |
4 | ['21A.S01']
5 | name = "Special Topic: Reimagining Indigeneity — Pathways of Identity, Cultural Expression, and Continuity in a Changing World"
6 | hassS = true
7 | lectureRawSections = ['4-163/W/0/11-2']
8 | lectureSections = [[[[66, 6]], '4-163']]
9 | inCharge = "J. Knox-Hayes, L. Jonas"
10 |
--------------------------------------------------------------------------------
/scrapers/overrides.toml.d/21g.toml:
--------------------------------------------------------------------------------
1 | #:schema ./override-schema.json
2 | # Course 21G special subjects (fall 2025)
3 |
4 | ['21G.312']
5 | name = "Basic Themes in French Literature and Culture: Science, Mysteries, and the Francophone World"
6 | description = "Discover the unknown through the stories of the French-speaking world. This course is designed for intermediate and advanced French learners who are curious about science, innovation, and culture. Through exciting topics like Marie Curie, artificial intelligence, climate change, and science fiction, you will improve your French while exploring the mysteries and big questions of our time — from the past to the future, and from Europe to Africa to the Caribbean."
7 |
--------------------------------------------------------------------------------
/scrapers/overrides.toml.d/21l.toml:
--------------------------------------------------------------------------------
1 | #:schema ./override-schema.json
2 | # Course 21L special subjects (fall 2025)
3 |
4 | ['21L.003']
5 | name = "Jane Austen: Reading Fiction"
6 |
7 | ['21L.050']
8 | name = "The Art of Seeing Things: Reading Nonfiction"
9 |
10 | ['21L.345']
11 | name = "Around the World in Short Film: On the Screen"
12 |
13 | ['21L.433']
14 | name = "Kubrick: Film Styles and Genres"
15 |
16 | ['21L.450']
17 | name = "Ecofeminism: Global Environmental Literature"
18 |
19 | ['21L.475']
20 | name = "Writers Responding to a Rapidly Modernizing World: Enlightenment and Modernity"
21 |
22 | ['21L.S89']
23 | name = "French Modernity: Special Subject in Literature"
24 |
25 | ['21L.703']
26 | name = "Murder & Mayhem Remade: Studies in Drama"
27 |
28 | ['21L.704']
29 | name = "The Poetry of Witness: Studies in Poetry"
30 |
31 | ['21L.706']
32 | name = "On Love: Studies in Film"
33 |
34 | ['21L.707']
35 | name = "The Art of War and Peace: Problems in Cultural Interpretation"
36 |
--------------------------------------------------------------------------------
/scrapers/overrides.toml.d/21m.toml:
--------------------------------------------------------------------------------
1 | ["21M.139"]
2 | name = "Introduction to Arranging"
3 | oldNumber = "Moments in Music: Composition B"
4 | prereqs = " ''Permission of instructor''"
5 | level = "U"
6 | lectureUnits = 2
7 | labUnits = 0
8 | preparationUnits = 4
9 | description = "Do you love listening to different covers of your favorite artists and songs? Are you intrigued by how a simple melody can be heard in a variety of colors and styles by different ensembles and instruments? The craft of arranging previously composed music, whether one’s own or another’s, is a way to express oneself musically in a variety of timbres, sounds, and textures. We will explore arranging as a bi-directional process: reducing a large score to a piano reduction and taking something as basic as a lead sheet melody with chords and expanding it to a larger vocal or instrumental piece. As a final project students will arrange a short piece of their choice for an a cappella or small instrumental ensemble.\n"
10 | hassA = true
11 | inCharge = "Garo Saraydarian"
12 | url = "https://mta.mit.edu/music/class-schedule"
13 |
14 | ["21M.299"]
15 | name = " Pirate Songs & Whaling Chanteys"
16 | oldNumber = "Studies in Global Musics"
17 | prereqs = "21M.030 or permission of Instructor"
18 | level = "U"
19 | lectureUnits = 3
20 | labUnits = 0
21 | preparationUnits = 9
22 | description = "In this class, we will explore the role of music in U.S. maritime culture—both factual and fictional. From African American dockworkers’ songs in 19th-century Southern ports, to chanteys on American whaling vessels, to 20th-century folk revivals, to the singing pirates of film and video games, we will examine the musics that have animated the U.S.’s cultural fascination with the sea. Students will analyze primary source material including song collections, recordings, compositions, and viral social media videos. The class will involve frequent singing as an exploration of the repertoire and the cultural phenomenon of participatory chantey sings. No prior musical experience is needed (we will teach you to sing!).\n\n"
23 | hassA = true
24 | inCharge = "J. Maurer"
25 | url = "https://mta.mit.edu/music/class-schedule"
--------------------------------------------------------------------------------
/scrapers/overrides.toml.d/4.toml:
--------------------------------------------------------------------------------
1 | #:schema ./override-schema.json
2 | # Course 6 special subjects (fall 2025)
3 |
4 | ["4.S14"]
5 | name = "Architecture of Longevity: Designs for the Third Age"
6 | description = "If Maria Montessori designed the tools and environment to meet the cognitive and physical stages of children, how might we similarly design our environment to meet the needs of the \"Young Old\"?\n\nThis workshop involves collecting, analyzing and drawing examples of designs for older adults from around the world across three ‘scales’: the body, the room, the street. Students in this course will help build an architectural index useful to help navigate the unprecedented \"Silver Tsunami\" that the United States and other industrialized countries have never before encountered, students will develop a variety of new designs on that can aid in alleviating the double housing and care crises that financially cripples 90% of older adults. Unless redressed, these financial burdens will in turn, fall on the shoulders of younger generations. How can we use architecture to reframe this opportunity and redesign our environments to fully embrace the cognitive, perceptual, and physical changes of humans across all ages — and thrive at each stage?"
7 | inCharge = "R. Segal"
8 | level = "G"
9 | lectureUnits = 2
10 | labUnits = 2
11 | preparationUnits = 5
12 |
13 | ["4.S22"]
14 | name = "System Change"
15 | description = "How do you go from a moment of obligation to starting or accelerating a movement?\n\nThis course explores the difference between innovation, social innovation, and systems change for social impact. Students interested in navigating complex environmental and social problems will explore frameworks and case studies from real systems change innovators to develop a more comprehensive view of complex problems and the systems they are part of —systems that often keep those problems in place.\n\nIn the course, you will apply experiential tools and methods to interrogate your own call to action, strengths, and gaps to address complex problems or needs. You will gain an understanding of the importance of understanding problems from the impact target’s perspective and explore innovative ways to create a scalable movement that ultimately can change a system. The final deliverable from the course is writing a case study on system change based on detailed actor mapping and interviews where you share your deeper understanding of a system you care about."
16 | inCharge = "Y. Jimenez"
17 | level = "G"
18 | lectureUnits = 2
19 | labUnits = 0
20 | preparationUnits = 7
21 |
22 | ["4.S24"]
23 | name = "Creative Careers"
24 | description = "How can you build your own creative practice in today’s international landscape—one that is sustainable, leverages innovation, and contributes meaningfully to the future of the cultural and creative sectors?\n\nThis half-semester course offers you, as a student in the arts, cultural, and creative fields, fundamental tools and strategies for designing your career as an independent professional or studio founder.\n\nYou will:\n\nA) Develop an understanding of the international framework of institutions, relationships, and policies that support professionals aiming to create impact through their creative practice—and learn how this knowledge can help you shape offerings that stand out and create a competitive advantage.\nB) Learn concepts and mechanisms commonly found in the economics of art and culture, and explore how critical issues can be transformed into strategic opportunities.\nC) Examine the diverse types of value generated by cultural production, discover how to combine them into distinctive offerings, and effectively communicate and market your work. You’ll also study business models within the creative industries and develop the adaptability to navigate evolving markets.\nD) Acquire practical skills in branding, legal business structures, and intellectual property—enabling you to protect and leverage your creative output while building a sustainable professional practice."
25 | inCharge = "G. Picchi"
26 | level = "G"
27 | lectureUnits = 3
28 | labUnits = 0
29 | preparationUnits = 3
30 |
31 | ["4.S28"]
32 | name = " X-Machine: AI and Design Innovation"
33 | description = "In an AI-enhanced future, humans will become better at everything. The machine targets real-world artificial intelligence challenges designed to help address issues related to climate change, and urbanization in cities. X Machine is a mini accelerator workshop course designed to unite computer science and design/architecture together to design and create innovative and impactful technological solutions to problems in the built and human environment. This half-semester course promotes the development of strategic thinking and technical exploration in the realm of AI, focusing on problem framing and early-stage ideation.\n\nThe course will allow students to develop a foundational knowledge of AI within an interdisciplinary context. Students will learn how to design and create a prototype, learn how to maximize their engagement with their users/customers, and learn how to determine the value proposition that will make an AI-empowered startup successful. By the end of this class, students will be able to develop a conceptual business plan for an AI-based technology solution and apply to other programs at MIT such as DesignX, Sandbox, Delta V, The Engine, and more. "
34 | inCharge = "N. Bayomi, N. Chang"
35 | lectureUnits = 2
36 | labUnits = 0
37 | preparationUnits = 6
38 | level = "G"
39 |
40 | ["4.S43"]
41 | name = "Applied Category Theory for Engineering Design"
42 | description = "Considers the multiple trade-offs at various abstraction levels and scales when designing complex, multi-component systems. Covers topics from foundational principles to advanced applications, emphasizing the role of compositional thinking in engineering. Introduces category theory as a mathematical framework for abstraction and composition, enabling a unified and modular approach to modeling, analyzing, and designing interconnected systems. Showcases successful applications in areas such as dynamical systems and automated system design optimization, with a focus on autonomous robotics and mobility. Offers students the opportunity to work on their own application through a dedicated project in the second half of the term. \n\nStudents taking graduate version complete additional assignments."
43 | lectureUnits = 3
44 | labUnits = 1
45 | preparationUnits = 8
46 | level = "G"
47 | inCharge = "G. Zardini"
48 | same = "1.144"
49 |
50 | ["4.S65"]
51 | name = "Decolonial Ecologies"
52 | description = "This seminar examines the relationship between political ecology, ecological crises, and the process of (de)colonization. Students will critically analyze historical understandings of decolonization and contemporary proposals for decolonial ecologies. Following Stefanie K. Dunning’s invocation “May our egos die so that the world may live,” this seminar asks, how can we continually transform our praxis on a personal and structural level to create the possibility and space for decolonial ecologies? And whose imaginations are presently shaping our collective futures? Open for cross-registration. And open to undergraduates with instructor’s permission."
53 | isVariableUnits = false
54 | level = "G"
55 | lectureUnits = 3
56 | labUnits = 0
57 | preparationUnits = 9
58 | inCharge = "H. Gupta"
59 |
--------------------------------------------------------------------------------
/scrapers/overrides.toml.d/6.toml:
--------------------------------------------------------------------------------
1 | #:schema ./override-schema.json
2 | # Course 6 special subjects (fall 2025)
3 |
4 | ['6.S056']
5 | name = "Hack Yourself: Data-driven Wellbeing and Learning"
6 | inCharge = "A. Bell"
7 |
8 | ['6.S061']
9 | name = "Humane User Experience Design"
10 | inCharge = "A. Satyanarayan"
11 |
12 | ['6.S077']
13 | name = "Life Science and Semiconductor"
14 | meets = "6.S897"
15 | inCharge = "A. Bahai, T. Heldt"
16 |
17 | ['6.S897']
18 | name = "Life Science and Semiconductor"
19 | meets = "6.S077"
20 | inCharge = "A. Bahai, T. Heldt"
21 |
22 | ['6.S890']
23 | name = "Topics in Mulitagent Learning"
24 | inCharge = "G. Farina"
25 |
26 | ['6.S892']
27 | name = "Advanced Topics in Power Electronics"
28 | inCharge = "D. Perreault"
29 |
30 | ['6.S894']
31 | name = "Accelerated Computing"
32 | inCharge = "J. Ragan-Kelley"
33 |
34 | ['6.S896']
35 | name = "Algorithmic Statistics"
36 | inCharge = "S. Hopkins"
37 |
38 | ['6.S965']
39 | name = "Digital Systems Laboratory II"
40 | inCharge = "J. Steinmeyer"
41 |
--------------------------------------------------------------------------------
/scrapers/overrides.toml.d/cms.toml:
--------------------------------------------------------------------------------
1 | #:schema ./override-schema.json
2 | # CMS special subjects (fall 2025)
3 |
4 | ["CMS.S61"]
5 | name = "Special Subject: STEAM Learning Architecture: A Framework for Educational Innovation"
6 | meets = "CMS.S97"
7 | hassS = true
8 | level = "U"
9 | lectureUnits = 2
10 | labUnits = 1
11 | preparationUnits = 9
12 | inCharge = "Claudia Urrea"
13 | description = "This course explores the creation of new learning environments and experiences through the lens of the STEAM Learning Architecture, a framework developed by pk-12 @ Open Learning to guide approaches to teaching and learning for educational innovation. The framework prioritizes student-centered, hands-on learning, rooted in the Constructionist theory that the most powerful learning occurs through discovery, exploration, and creation. Guest speakers and site visits will provide context for final projects that produce innovative learning experiences for k-12 audiences. Class meets Tuesdays 10-12, Recitation is Thursdays 10-11."
14 |
15 | ["CMS.S97"]
16 | name = "Special Subject: STEAM Learning Architecture: A Framework for Educational Innovation"
17 | meets = "CMS.S61"
18 | level = "G"
19 | lectureUnits = 2
20 | labUnits = 1
21 | preparationUnits = 9
22 | inCharge = "Claudia Urrea"
23 | description = "This course explores the creation of new learning environments and experiences through the lens of the STEAM Learning Architecture, a framework developed by pk-12 @ Open Learning to guide approaches to teaching and learning for educational innovation. The framework prioritizes student-centered, hands-on learning, rooted in the Constructionist theory that the most powerful learning occurs through discovery, exploration, and creation. Guest speakers and site visits will provide context for final projects that produce innovative learning experiences for k-12 audiences. Class meets Tuesdays 10-12, Recitation is Thursdays 10-11."
24 |
--------------------------------------------------------------------------------
/scrapers/package.py:
--------------------------------------------------------------------------------
1 | """
2 | We combine the data from the Fireroad API and the data we scrape from the
3 | catalog, into the format specified by src/lib/rawClass.ts.
4 |
5 | Functions:
6 | load_json_data(jsonfile): Loads data from the provided JSON file
7 | merge_data(datasets, keys_to_keep): Combines the datasets.
8 | run(): The main entry point.
9 |
10 | Dependencies:
11 | datetime
12 | json
13 | utils (within this folder)
14 | """
15 |
16 | from __future__ import annotations
17 |
18 | import datetime
19 | import json
20 | import os
21 | import os.path
22 | import sys
23 | from typing import Any, Union
24 | from collections.abc import Iterable
25 |
26 | from .utils import get_term_info
27 |
28 | if sys.version_info >= (3, 11):
29 | import tomllib
30 | else:
31 | import tomli as tomllib
32 |
33 |
34 | package_dir = os.path.dirname(__file__)
35 |
36 |
37 | def load_json_data(json_path: str) -> Any:
38 | """
39 | Loads data from the provided file
40 |
41 | Args:
42 | * json_path (str): The file to load from
43 |
44 | Returns:
45 | * any: The data contained within the file
46 | """
47 | json_path = os.path.join(package_dir, json_path)
48 | with open(json_path, mode="r", encoding="utf-8") as json_file:
49 | return json.load(json_file)
50 |
51 |
52 | def load_toml_data(toml_dir: str) -> dict[str, Any]:
53 | """
54 | Loads data from the provided directory that consists exclusively of TOML files
55 |
56 | Args:
57 | * tomldir (str): The directory to load from
58 |
59 | Returns:
60 | * dict: The data contained within the directory
61 | """
62 | toml_dir = os.path.join(package_dir, toml_dir)
63 | out: dict[str, Any] = {}
64 | for fname in os.listdir(toml_dir):
65 | if fname.endswith(".toml"):
66 | with open(os.path.join(toml_dir, fname), "rb") as toml_file:
67 | out.update(tomllib.load(toml_file))
68 | return out
69 |
70 |
71 | def merge_data(
72 | datasets: Iterable[dict[Any, dict[str, Any]]], keys_to_keep: Iterable[str]
73 | ) -> dict[Any, dict[str, Any]]:
74 | """
75 | Combines the provided datasets, retaining only keys from keys_to_keep.
76 |
77 | .. note::
78 | Later datasets will override earlier ones
79 |
80 | Args:
81 | * datasets (iterable[dict[any, dict]]):
82 | * keys_to_keep (iterable): The keys to retain in the output
83 |
84 | Returns:
85 | * dict[any, dict]: The combined data
86 | """
87 | result: dict[str, dict[str, Any]] = {k: {} for k in keys_to_keep}
88 | for key in keys_to_keep:
89 | for dataset in datasets:
90 | if key in dataset:
91 | result[key].update(dataset[key])
92 | return result
93 |
94 |
95 | # pylint: disable=too-many-locals
96 | def run() -> None:
97 | """
98 | The main entry point.
99 | Takes data from fireroad.json and catalog.json; outputs latest.json.
100 | There are no arguments and no return value.
101 | """
102 | fireroad_presem = load_json_data("fireroad-presem.json")
103 | fireroad_sem = load_json_data("fireroad-sem.json")
104 | catalog = load_json_data("catalog.json")
105 | cim = load_json_data("cim.json")
106 | overrides = load_toml_data("overrides.toml.d")
107 |
108 | # The key needs to be in BOTH fireroad and catalog to make it:
109 | # If it's not in Fireroad, it's not offered in this semester (fall, etc.).
110 | # If it's not in catalog, it's not offered this year.
111 | courses_presem = merge_data(
112 | datasets=[fireroad_presem, catalog, cim, overrides],
113 | keys_to_keep=set(fireroad_presem) & set(catalog),
114 | )
115 | courses_sem = merge_data(
116 | datasets=[fireroad_sem, catalog, cim, overrides],
117 | keys_to_keep=set(fireroad_sem) & set(catalog),
118 | )
119 |
120 | term_info_presem = get_term_info(False)
121 | url_name_presem = term_info_presem["urlName"]
122 | term_info_sem = get_term_info(True)
123 | url_name_sem = term_info_sem["urlName"]
124 | now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
125 |
126 | obj_presem: dict[str, Union[dict[str, Any], str, dict[Any, dict[str, Any]]]] = {
127 | "termInfo": term_info_presem,
128 | "lastUpdated": now,
129 | "classes": courses_presem,
130 | }
131 | obj_sem: dict[str, Union[dict[str, Any], str, dict[Any, dict[str, Any]]]] = {
132 | "termInfo": term_info_sem,
133 | "lastUpdated": now,
134 | "classes": courses_sem,
135 | }
136 |
137 | with open(
138 | os.path.join(package_dir, f"../public/{url_name_presem}.json"),
139 | mode="w",
140 | encoding="utf-8",
141 | ) as presem_file:
142 | json.dump(obj_presem, presem_file, separators=(",", ":"))
143 |
144 | with open(
145 | os.path.join(package_dir, "../public/latest.json"), mode="w", encoding="utf-8"
146 | ) as latest_file:
147 | json.dump(obj_sem, latest_file, separators=(",", ":"))
148 |
149 | print(f"{url_name_presem}: got {len(courses_presem)} courses")
150 | print(f"{url_name_sem}: got {len(courses_sem)} courses")
151 |
152 |
153 | if __name__ == "__main__":
154 | run()
155 |
--------------------------------------------------------------------------------
/scrapers/text_mining.py:
--------------------------------------------------------------------------------
1 | """
2 | Mines hydrant data
3 |
4 | Functions:
5 | has_keyword(sometext)
6 | find_key_sentences(sometext)
7 | get_description_list(dataset)
8 | get_my_data()
9 | find_matching_records(descriptions)
10 | run()
11 |
12 | Constants:
13 | KEYWORDS
14 | FOLDER
15 | FILEPATHS
16 | """
17 |
18 | from __future__ import annotations
19 |
20 | import json
21 | from typing import Iterable
22 | from collections.abc import Mapping
23 | from nltk.tokenize import word_tokenize, sent_tokenize # type: ignore
24 |
25 | KEYWORDS = ["limited", "restricted", "enrollment", "preference", "priority"]
26 | FOLDER = "../public/"
27 | FILEPATHS = ["f22.json", "f23.json", "f24.json", "i25.json", "s23.json", "s24.json"]
28 |
29 |
30 | def has_keyword(sometext: str) -> bool:
31 | """
32 | Checks if the given text contains any of the keywords.
33 |
34 | Args:
35 | sometext (str): The text to search for keywords
36 |
37 | Returns:
38 | bool: True if sometext contains a keyword, False otherwise
39 | """
40 | words = word_tokenize(sometext) # word_tokenize better than the in operator
41 | lowered_words = [w.lower() for w in words] # make it case insensitive
42 | for keyword in KEYWORDS:
43 | if keyword in lowered_words:
44 | return True
45 | return False
46 |
47 |
48 | def find_key_sentences(sometext: str) -> list[str]:
49 | """
50 | Returns a list of all sentences that contain a keyword
51 |
52 | Args:
53 | sometext (str): The text to search for keywords
54 |
55 | Returns:
56 | list[str]: A list of sentences that contain a keyword
57 | """
58 | my_sentences = sent_tokenize(sometext) # sent_tokenize is much better than .split()
59 | return [sentence for sentence in my_sentences if has_keyword(sentence)]
60 |
61 |
62 | def get_description_list(
63 | dataset: Mapping[str, Mapping[str, Mapping[str, str]]],
64 | ) -> list[str]:
65 | """
66 | Obtains a list of descriptions from the dataset
67 |
68 | Args:
69 | dataset (Mapping[str, Mapping[str, Mapping[str, str]]]):
70 | The dataset containing class information
71 |
72 | Returns:
73 | list[str]: A list of descriptions from the dataset
74 | """
75 | classlist = dataset["classes"].values()
76 | return [record["description"] for record in classlist]
77 |
78 |
79 | def get_my_data() -> list[str]:
80 | """
81 | obtains the data
82 |
83 | Returns:
84 | list[str]: A list of descriptions from all the JSON files
85 | """
86 | descriptions: list[str] = []
87 | for filepath in FILEPATHS:
88 | full_path = FOLDER + filepath
89 | with open(full_path, "r", encoding="utf-8") as file:
90 | rawdata = json.load(file)
91 | descriptions.extend(get_description_list(rawdata))
92 | return descriptions
93 |
94 |
95 | def find_matching_records(descriptions: Iterable[str]) -> list[str]:
96 | """
97 | find sentences from record descriptions that contain a keyword
98 |
99 | Args:
100 | descriptions (Iterable[str]): A list of descriptions to search for keywords
101 |
102 | Returns:
103 | list[str]: A sorted list of unique sentences that contain a keyword
104 | """
105 | result: list[str] = []
106 | for description in descriptions:
107 | result.extend(find_key_sentences(description))
108 | return list(sorted(set(result)))
109 |
110 |
111 | def run() -> None:
112 | """
113 | The main function!
114 | """
115 | mydata = get_my_data()
116 | mymatches = find_matching_records(mydata)
117 | for match in mymatches:
118 | print(match)
119 |
120 |
121 | if __name__ == "__main__":
122 | run()
123 |
--------------------------------------------------------------------------------
/scrapers/utils.py:
--------------------------------------------------------------------------------
1 | """
2 | Utility data and functions for the scrapers folder.
3 |
4 | Data:
5 | GIR_REWRITE: dict[str, str]
6 | TIMESLOTS: int
7 | DAYS: dict[str, int]
8 | TIMES: dict[str, int]
9 | EVE_TIMES: dict[str, int]
10 | Term: enum.EnumType
11 |
12 | Functions:
13 | find_timeslot(day, slot, pm)
14 | zip_strict(*iterables)
15 | grouper(iterable, n)
16 | get_term_info()
17 | """
18 |
19 | from itertools import zip_longest
20 | import json
21 | import os.path
22 | from enum import Enum
23 | from typing import Any, Dict, Generator, Iterable, Tuple
24 |
25 | GIR_REWRITE = {
26 | "GIR:CAL1": "Calculus I (GIR)",
27 | "GIR:CAL2": "Calculus II (GIR)",
28 | "GIR:PHY1": "Physics I (GIR)",
29 | "GIR:PHY2": "Physics II (GIR)",
30 | "GIR:CHEM": "Chemistry (GIR)",
31 | "GIR:BIOL": "Biology (GIR)",
32 | }
33 |
34 | TIMESLOTS = 30
35 |
36 | DAYS = {
37 | "M": 0,
38 | "T": TIMESLOTS,
39 | "W": TIMESLOTS * 2,
40 | "R": TIMESLOTS * 3,
41 | "F": TIMESLOTS * 4,
42 | }
43 |
44 | TIMES = {
45 | "8": 0,
46 | "8.30": 1,
47 | "9": 2,
48 | "9.30": 3,
49 | "10": 4,
50 | "10.30": 5,
51 | "11": 6,
52 | "11.30": 7,
53 | "12": 8,
54 | "12.30": 9,
55 | "1": 10,
56 | "1.30": 11,
57 | "2": 12,
58 | "2.30": 13,
59 | "3": 14,
60 | "3.30": 15,
61 | "4": 16,
62 | "4.30": 17,
63 | "5": 18,
64 | "5.30": 19,
65 | }
66 |
67 | EVE_TIMES = {
68 | "12": 8,
69 | "12.30": 9,
70 | "1": 10,
71 | "1.30": 11,
72 | "2": 12,
73 | "2.30": 13,
74 | "3": 14,
75 | "3.30": 15,
76 | "4": 16,
77 | "4.30": 17,
78 | "5": 18,
79 | "5.30": 19,
80 | "6": 20,
81 | "6.30": 21,
82 | "7": 22,
83 | "7.30": 23,
84 | "8": 24,
85 | "8.30": 25,
86 | "9": 26,
87 | "9.30": 27,
88 | "10": 28,
89 | "10.30": 29,
90 | }
91 |
92 | MONTHS = {
93 | "jan": 1,
94 | "feb": 2,
95 | "mar": 3,
96 | "apr": 4,
97 | "may": 5,
98 | "jun": 6,
99 | "jul": 7,
100 | "aug": 8,
101 | "sep": 9,
102 | "oct": 10,
103 | "nov": 11,
104 | "dec": 12,
105 | }
106 |
107 |
108 | class Term(Enum):
109 | """Terms for the academic year."""
110 |
111 | FA = "fall"
112 | JA = "IAP"
113 | SP = "spring"
114 | SU = "summer"
115 |
116 |
117 | def find_timeslot(day: str, slot: str, is_slot_pm: bool) -> int:
118 | """
119 | Finds the numeric code for a timeslot.
120 | Example: find_timeslot("W", "11.30", False) -> 67
121 |
122 | Args:
123 | day (str): The day of the timeslot
124 | slot (str): The time of the timeslot
125 | is_slot_pm (bool): Whether the timeslot is in the evening
126 |
127 | Raises:
128 | ValueError: If no matching timeslot could be found.
129 |
130 | Returns:
131 | int: A numeric code for the timeslot
132 | """
133 | time_dict = EVE_TIMES if is_slot_pm else TIMES
134 | if day not in DAYS or slot not in time_dict: # error handling!
135 | raise ValueError(f"Invalid timeslot {day}, {slot}, {is_slot_pm}")
136 | return DAYS[day] + time_dict[slot]
137 |
138 |
139 | def zip_strict(*iterables: Iterable[Any]) -> Generator[Tuple[Any, ...], Any, None]:
140 | """
141 | Helper function for grouper.
142 | Groups values of the iterator on the same iteration together.
143 |
144 | Raises:
145 | ValueError: If iterables have different lengths.
146 |
147 | Yields:
148 | Tuple[Any, ...]: A generator, which you can iterate over.
149 | """
150 | sentinel = object()
151 | for group in zip_longest(*iterables, fillvalue=sentinel):
152 | if any(sentinel is t for t in group):
153 | raise ValueError("Iterables have different lengths")
154 | yield group
155 |
156 |
157 | def grouper(
158 | iterable: Iterable[Any], group_size: int
159 | ) -> Generator[Tuple[Any, ...], Any, None]:
160 | """
161 | Groups items of the iterable in equally spaced blocks of group_size items.
162 | If the iterable's length ISN'T a multiple of group_size, you'll get a
163 | ValueError on the last iteration.
164 |
165 | >>> list(grouper("ABCDEFGHI", 3))
166 | [('A', 'B', 'C'), ('D', 'E', 'F'), ('G', 'H', 'I')]
167 |
168 | From https://docs.python.org/3/library/itertools.html#itertools-recipes.
169 |
170 | Args:
171 | iterable (Iterable[Any]): an iterator
172 | group_size (int): The size of the groups
173 |
174 | Returns:
175 | Generator[Tuple[Any, ...], Any, None]:
176 | The result of the grouping, which you can iterate over.
177 | """
178 | args = [iter(iterable)] * group_size
179 | return zip_strict(*args)
180 |
181 |
182 | def get_term_info(is_semester_term: bool) -> Dict[str, Any]:
183 | """
184 | Gets the latest term info from "../public/latestTerm.json" as a dictionary.
185 | If is_semester_term = True, looks at semester term (fall/spring).
186 | If is_semester_term = False, looks at pre-semester term (summer/IAP)
187 |
188 | Args:
189 | is_semester_term (bool): whether to look at the semester
190 | or the pre-semester term.
191 |
192 | Returns:
193 | Dict[str, Any]: the term info for the selected term from latestTerm.json.
194 | """
195 | fname = os.path.join(os.path.dirname(__file__), "../public/latestTerm.json")
196 | with open(fname, encoding="utf-8") as latest_term_file:
197 | term_info = json.load(latest_term_file)
198 | if is_semester_term:
199 | return term_info["semester"]
200 |
201 | return term_info["preSemester"]
202 |
203 |
204 | def url_name_to_term(url_name: str) -> Term:
205 | """
206 | Extract the term (without academic year) from a urlName.
207 |
208 | >>> url_name_to_term("f24")
209 | Term.FA
210 |
211 | Args:
212 | url_name (string): a urlName representing a term, as found in latestTerm.json.
213 |
214 | Raises:
215 | ValueError: If the url_name does not start with a valid term character.
216 |
217 | Returns:
218 | Term: the enum value corresponding to the current term (without academic year).
219 | """
220 | if url_name[0] == "f":
221 | return Term.FA
222 | if url_name[0] == "i":
223 | return Term.JA
224 | if url_name[0] == "s":
225 | return Term.SP
226 | if url_name[0] == "m":
227 | return Term.SU
228 |
229 | raise ValueError(f"Invalid term {url_name[0]}")
230 |
--------------------------------------------------------------------------------
/src/assets/Lab.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/Lab.gif
--------------------------------------------------------------------------------
/src/assets/PartLab.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/PartLab.gif
--------------------------------------------------------------------------------
/src/assets/calendar-button.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/calendar-button.png
--------------------------------------------------------------------------------
/src/assets/cih.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/cih.gif
--------------------------------------------------------------------------------
/src/assets/cihw.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/cihw.gif
--------------------------------------------------------------------------------
/src/assets/fall.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/fall.gif
--------------------------------------------------------------------------------
/src/assets/fuzzAndAnt.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/fuzzAndAnt.png
--------------------------------------------------------------------------------
/src/assets/fuzzball.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/fuzzball.png
--------------------------------------------------------------------------------
/src/assets/grad.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/grad.gif
--------------------------------------------------------------------------------
/src/assets/hassA.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/hassA.gif
--------------------------------------------------------------------------------
/src/assets/hassE.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/hassE.gif
--------------------------------------------------------------------------------
/src/assets/hassH.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/hassH.gif
--------------------------------------------------------------------------------
/src/assets/hassS.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/hassS.gif
--------------------------------------------------------------------------------
/src/assets/hydraAnt.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/hydraAnt.png
--------------------------------------------------------------------------------
/src/assets/iap.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/iap.gif
--------------------------------------------------------------------------------
/src/assets/logo-dark.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
52 |
--------------------------------------------------------------------------------
/src/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
52 |
--------------------------------------------------------------------------------
/src/assets/nonext.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/nonext.gif
--------------------------------------------------------------------------------
/src/assets/repeat.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/repeat.gif
--------------------------------------------------------------------------------
/src/assets/rest.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/rest.gif
--------------------------------------------------------------------------------
/src/assets/simple-fuzzball.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/simple-fuzzball.png
--------------------------------------------------------------------------------
/src/assets/spring.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/spring.gif
--------------------------------------------------------------------------------
/src/assets/summer.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/summer.gif
--------------------------------------------------------------------------------
/src/assets/under.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sipb/hydrant/e5baeaffafb1b27a14aec682afd10cfc8fd8db70/src/assets/under.gif
--------------------------------------------------------------------------------
/src/components/ActivityDescription.tsx:
--------------------------------------------------------------------------------
1 | import { useContext } from "react";
2 | import { decode } from "html-entities";
3 |
4 | import { Flex, Heading, Image, Link, Text, Button } from "@chakra-ui/react";
5 | import { useColorMode } from "./ui/color-mode";
6 | import { Tooltip } from "./ui/tooltip";
7 |
8 | import type { NonClass } from "../lib/activity";
9 | import type { Flags } from "../lib/class";
10 | import { Class, DARK_IMAGES, getFlagImg } from "../lib/class";
11 | import { linkClasses } from "../lib/utils";
12 | import { HydrantContext } from "../lib/hydrant";
13 |
14 | import { ClassButtons, NonClassButtons } from "./ActivityButtons";
15 | import { LuExternalLink } from "react-icons/lu";
16 |
17 | /** A small image indicating a flag, like Spring or CI-H. */
18 | function TypeSpan(props: { flag?: keyof Flags; title: string }) {
19 | const { flag, title } = props;
20 | const { colorMode } = useColorMode();
21 | const filter =
22 | colorMode === "dark" && DARK_IMAGES.includes(flag ?? "") ? "invert()" : "";
23 |
24 | return flag ? (
25 |
26 |
33 |
34 | ) : (
35 | <>{title}>
36 | );
37 | }
38 |
39 | /** Header for class description; contains flags and related classes. */
40 | function ClassTypes(props: { cls: Class }) {
41 | const { cls } = props;
42 | const { state } = useContext(HydrantContext);
43 | const { flags, totalUnits, units } = cls;
44 |
45 | /**
46 | * Wrap a group of flags in TypeSpans.
47 | *
48 | * @param arr - Arrays with [flag name, alt text].
49 | */
50 | const makeFlags = (arr: [keyof Flags, string][]) =>
51 | arr
52 | .filter(([flag, _]) => flags[flag])
53 | .map(([flag, title]) => (
54 |
55 | ));
56 |
57 | const currentYear = parseInt(state.term.fullRealYear);
58 | const nextAcademicYearStart =
59 | state.term.semester === "f" ? currentYear + 1 : currentYear;
60 | const nextAcademicYearEnd = nextAcademicYearStart + 1;
61 |
62 | const types1 = makeFlags([
63 | [
64 | "nonext",
65 | `Not offered ${nextAcademicYearStart.toString()}-${nextAcademicYearEnd.toString()}`,
66 | ],
67 | ["under", "Undergrad"],
68 | ["grad", "Graduate"],
69 | ]);
70 |
71 | const seasons = makeFlags([
72 | ["fall", "Fall"],
73 | ["iap", "IAP"],
74 | ["spring", "Spring"],
75 | ["summer", "Summer"],
76 | ])
77 | .map((tag) => [tag, ", "])
78 | .flat()
79 | .slice(0, -1);
80 |
81 | const types2 = makeFlags([
82 | ["repeat", "Can be repeated for credit"],
83 | ["rest", "REST"],
84 | ["Lab", "Institute Lab"],
85 | ["PartLab", "Partial Institute Lab"],
86 | ["hassH", "HASS-H"],
87 | ["hassA", "HASS-A"],
88 | ["hassS", "HASS-S"],
89 | ["hassE", "HASS-E"],
90 | ["cih", "CI-H"],
91 | ["cihw", "CI-HW"],
92 | ]);
93 |
94 | const halfType =
95 | flags.half === 1 ? (
96 |
97 | ) : flags.half === 2 ? (
98 |
99 | ) : (
100 | ""
101 | );
102 |
103 | const unitsDescription = cls.isVariableUnits
104 | ? "Units arranged"
105 | : `${totalUnits.toString()} units: ${units.join("-")}`;
106 |
107 | return (
108 |
109 |
110 | {types1}
111 |
112 | ({seasons})
113 |
114 | {types2}
115 | {halfType}
116 |
117 | {unitsDescription}
118 | {flags.final ? Has final : null}
119 |
120 | );
121 | }
122 |
123 | /** List of related classes, appears after flags and before description. */
124 | function ClassRelated(props: { cls: Class }) {
125 | const { cls } = props;
126 | const { state } = useContext(HydrantContext);
127 | const { prereq, same, meets } = cls.related;
128 |
129 | return (
130 | <>
131 | Prereq: {linkClasses(state, prereq)}
132 | {same !== "" && Same class as: {linkClasses(state, same)}}
133 | {meets !== "" && Meets with: {linkClasses(state, meets)} }
134 | >
135 | );
136 | }
137 |
138 | /** List of programs for which this class is a CI-M. */
139 | function ClassCIM(props: { cls: Class }) {
140 | const { cls } = props;
141 | const { cim } = cls;
142 |
143 | const url =
144 | "https://registrar.mit.edu/registration-academics/academic-requirements/communication-requirement/ci-m-subjects/subject";
145 |
146 | if (cim.length > 0) {
147 | return (
148 |
149 | CI-M for: {cim.join("; ")} (
150 |
151 | more info
152 |
153 | )
154 |
155 | );
156 | } else {
157 | return null;
158 | }
159 | }
160 |
161 | /** Class evaluation info. */
162 | function ClassEval(props: { cls: Class }) {
163 | const { cls } = props;
164 | const { rating, hours, people } = cls.evals;
165 |
166 | return (
167 |
168 | Rating: {rating}
169 | Hours: {hours}
170 | Avg # of students: {people}
171 |
172 | );
173 | }
174 |
175 | /** Class description, person in-charge, and any URLs afterward. */
176 | function ClassBody(props: { cls: Class }) {
177 | const { cls } = props;
178 | const { state } = useContext(HydrantContext);
179 | const { description, inCharge, extraUrls } = cls.description;
180 |
181 | return (
182 |
183 |
184 | {linkClasses(state, decode(description))}
185 |
186 | {inCharge !== "" && In-charge: {inCharge}.}
187 | {extraUrls.length > 0 && (
188 |
189 | {extraUrls.map(({ label, url }) => (
190 |
198 | {label}
199 |
200 | ))}
201 |
202 | )}
203 |
204 | );
205 | }
206 |
207 | /** Full class description, from title to URLs at the end. */
208 | function ClassDescription(props: { cls: Class }) {
209 | const { cls } = props;
210 |
211 | return (
212 |
213 |
214 | {cls.number}: {cls.name}
215 |
216 |
217 |
218 |
219 |
220 |
221 |
222 |
223 |
224 |
225 | );
226 | }
227 |
228 | /** Full non-class activity description, from title to timeslots. */
229 | function NonClassDescription(props: { activity: NonClass }) {
230 | const { activity } = props;
231 | const { state } = useContext(HydrantContext);
232 |
233 | return (
234 |
235 |
236 |
237 | {activity.timeslots.map((t) => (
238 |
239 |
247 | {t.toString()}
248 |
249 | ))}
250 |
251 |
252 | );
253 | }
254 |
255 | /** Activity description, whether class or non-class. */
256 | export function ActivityDescription() {
257 | const { hydrantState } = useContext(HydrantContext);
258 | const { viewedActivity: activity } = hydrantState;
259 | if (!activity) {
260 | return null;
261 | }
262 |
263 | return activity instanceof Class ? (
264 |
265 | ) : (
266 |
267 | );
268 | }
269 |
--------------------------------------------------------------------------------
/src/components/Calendar.scss:
--------------------------------------------------------------------------------
1 | .fc .fc-scrollgrid,
2 | .fc .fc-scrollgrid-section > td,
3 | .fc .fc-timegrid-axis {
4 | border-color: transparent;
5 |
6 | .dark & {
7 | border-color: transparent;
8 | }
9 | }
10 |
11 | .fc .fc-timegrid-slot {
12 | border-color: var(--chakra-colors-border-emphasized);
13 | border-width: 1.5px;
14 | border-left-color: transparent;
15 | }
16 |
17 | .fc-theme-standard td,
18 | .fc-theme-standard th {
19 | border-color: var(--chakra-colors-border-emphasized);
20 | border-width: 1.5px;
21 | }
22 |
23 | .fc .fc-scrollgrid-section > th:nth-child(1),
24 | .fc .fc-col-header-cell:last-child {
25 | border-right-color: transparent;
26 | }
27 |
28 | .fc .fc-scrollgrid-section-sticky > * {
29 | background: transparent;
30 | }
31 |
32 | .fc .fc-col-header-cell .fc-scrollgrid-sync-inner {
33 | background: var(--chakra-colors-bg);
34 | }
35 |
36 | .fc .fc-timegrid-event-harness-inset .fc-timegrid-event {
37 | box-shadow: var(--chakra-colors-bg) 0px 0px 0px 1.5px;
38 | }
39 |
40 | .fc .fc-scroller-harness {
41 | overflow: visible;
42 | }
43 |
44 | .fc .fc-timegrid-slot-label {
45 | border: none;
46 | }
47 |
48 | .fc .fc-timegrid-slot-label-frame {
49 | position: relative;
50 | }
51 |
52 | .fc .fc-timegrid-slot-label-cushion {
53 | font-size: 0.75rem;
54 | line-height: 1.3;
55 | opacity: 0.7;
56 | position: absolute;
57 | top: -1.25rem;
58 | right: 0.25rem;
59 | z-index: 2;
60 | }
61 |
62 | .fc .fc-timegrid-event {
63 | border-radius: 0;
64 | border-width: 0;
65 | padding-left: 0.25rem;
66 | }
67 |
68 | .fc .fc-col-header-cell-cushion {
69 | font-size: 0.8rem;
70 | letter-spacing: 0.05rem;
71 | text-transform: uppercase;
72 | }
73 |
--------------------------------------------------------------------------------
/src/components/Calendar.tsx:
--------------------------------------------------------------------------------
1 | import { useContext } from "react";
2 |
3 | import { Box, Text } from "@chakra-ui/react";
4 | import { Tooltip } from "./ui/tooltip";
5 |
6 | import FullCalendar from "@fullcalendar/react";
7 | import type { EventContentArg } from "@fullcalendar/core";
8 | import timeGridPlugin from "@fullcalendar/timegrid";
9 | import interactionPlugin from "@fullcalendar/interaction";
10 |
11 | import type { Activity } from "../lib/activity";
12 | import { NonClass, Timeslot } from "../lib/activity";
13 | import { Slot } from "../lib/dates";
14 | import { Class } from "../lib/class";
15 | import { HydrantContext } from "../lib/hydrant";
16 |
17 | import "./Calendar.scss";
18 |
19 | /**
20 | * Calendar showing all the activities, including the buttons on top that
21 | * change the schedule option selected.
22 | */
23 | export function Calendar() {
24 | const { state, hydrantState } = useContext(HydrantContext);
25 | const { selectedActivities, viewedActivity } = hydrantState;
26 |
27 | const renderEvent = ({ event }: EventContentArg) => {
28 | const TitleText = () => (
29 |
36 | {event.title}
37 |
38 | );
39 |
40 | return (
41 |
48 | {event.extendedProps.activity instanceof Class ? (
49 |
55 | ) : (
56 |
57 | )}
58 | {event.extendedProps.room}
59 |
60 | );
61 | };
62 |
63 | return (
64 | act.events)
72 | .flatMap((event) => event.eventInputs)}
73 | eventContent={renderEvent}
74 | eventClick={(e) => {
75 | // extendedProps: non-standard props of {@link Event.eventInputs}
76 | state.setViewedActivity(e.event.extendedProps.activity as Activity);
77 | }}
78 | headerToolbar={false}
79 | height="auto"
80 | // a date that is, conveniently enough, a monday
81 | initialDate="2001-01-01"
82 | slotDuration="00:30:00"
83 | slotLabelFormat={({ date }) => {
84 | const { hour } = date;
85 | return hour === 12
86 | ? "noon"
87 | : hour < 12
88 | ? `${hour.toString()} AM`
89 | : `${(hour - 12).toString()} PM`;
90 | }}
91 | slotMinTime="08:00:00"
92 | slotMaxTime="22:00:00"
93 | weekends={false}
94 | selectable={viewedActivity instanceof NonClass}
95 | select={(e) => {
96 | if (viewedActivity instanceof NonClass) {
97 | state.addTimeslot(
98 | viewedActivity,
99 | Timeslot.fromStartEnd(
100 | Slot.fromStartDate(e.start),
101 | Slot.fromStartDate(e.end),
102 | ),
103 | );
104 | }
105 | }}
106 | />
107 | );
108 | }
109 |
--------------------------------------------------------------------------------
/src/components/ClassTable.scss:
--------------------------------------------------------------------------------
1 | .ag-root {
2 | width: 100%;
3 | }
4 |
5 | .ag-row {
6 | cursor: pointer;
7 | }
8 |
9 | .ag-row:hover .ag-cell:first-child {
10 | text-decoration: underline;
11 | }
12 |
13 | .ag-cell-muted-text {
14 | color: var(--chakra-colors-fg-muted);
15 | }
16 |
17 | .ag-cell-success-text {
18 | color: var(--chakra-colors-fg-success);
19 | }
20 |
21 | .ag-cell-warning-text {
22 | color: var(--chakra-colors-fg-warning);
23 | }
24 |
25 | .ag-cell-error-text {
26 | color: var(--chakra-colors-fg-error);
27 | }
28 |
29 | .ag-cell-normal-text {
30 | color: var(--chakra-colors-fg);
31 | }
32 |
--------------------------------------------------------------------------------
/src/components/FeedbackBanner.tsx:
--------------------------------------------------------------------------------
1 | import { useContext } from "react";
2 |
3 | import {
4 | Center,
5 | Flex,
6 | Text,
7 | Box,
8 | Float,
9 | Presence,
10 | CloseButton,
11 | } from "@chakra-ui/react";
12 | import { LinkButton } from "./ui/link-button";
13 |
14 | import { HydrantContext } from "../lib/hydrant";
15 |
16 | export const FeedbackBanner = () => {
17 | const { state } = useContext(HydrantContext);
18 |
19 | return (
20 |
28 |
29 |
39 |
40 |
41 | Do you have feedback on Hydrant? We'd love to hear it!
42 |
43 |
55 | Contact us
56 |
57 | {
63 | state.showFeedback = false;
64 | }}
65 | />
66 |
67 |
68 |
69 | {
74 | state.showFeedback = false;
75 | }}
76 | />
77 |
78 |
79 |
80 | );
81 | };
82 |
--------------------------------------------------------------------------------
/src/components/Footers.tsx:
--------------------------------------------------------------------------------
1 | import { useContext, useState } from "react";
2 |
3 | import {
4 | DialogRoot,
5 | DialogBody,
6 | DialogContent,
7 | DialogFooter,
8 | DialogHeader,
9 | DialogTitle,
10 | DialogActionTrigger,
11 | } from "./ui/dialog";
12 | import { Flex, Link, Text, Button, Image } from "@chakra-ui/react";
13 |
14 | import fuzzAndAnt from "../assets/fuzzAndAnt.png";
15 | import { HydrantContext } from "../lib/hydrant";
16 |
17 | function AboutDialog() {
18 | const [visible, setVisible] = useState(false);
19 |
20 | return (
21 | <>
22 | {
24 | setVisible(true);
25 | }}
26 | colorPalette="blue"
27 | >
28 | About
29 |
30 | {
33 | setVisible(false);
34 | }}
35 | >
36 |
37 |
38 | Hydrant
39 |
40 |
41 |
42 |
43 | Hydrant is a student-run class planner for MIT students,
44 | maintained by SIPB, the{" "}
45 |
46 | Student Information Processing Board
47 |
48 | .
49 |
50 |
51 | We welcome contributions! View the source code or file issues on{" "}
52 |
56 | GitHub
57 |
58 | , or come to a SIPB meeting and ask how to help.
59 |
60 |
61 | We'd like to thank CJ Quines '23 for creating Hydrant and Edward
62 | Fan '19 for creating{" "}
63 |
64 | Firehose
65 |
66 | , the basis for Hydrant. We'd also like to thank the{" "}
67 |
68 | FireRoad
69 | {" "}
70 | team for collaborating with us.
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 | >
82 | );
83 | }
84 |
85 | function PrivacyPolicyDialog() {
86 | const [visible, setVisible] = useState(false);
87 |
88 | return (
89 | <>
90 | {
92 | setVisible(true);
93 | }}
94 | colorPalette="blue"
95 | >
96 | Privacy Policy
97 |
98 | {
101 | setVisible(false);
102 | }}
103 | >
104 |
105 |
106 | Privacy Policy
107 |
108 |
109 |
110 |
111 | Hydrant does not store any of your data outside of your browser.
112 | Data is only transmitted upstream when you export to Google
113 | Calendar. When you export to Google Calendar, Hydrant sends
114 | calendar information to Google to place into your calendar.
115 |
116 |
117 | No data is transmitted otherwise. That means that our servers do
118 | not store your class or calendar information. If you never
119 | export to Google Calendar we never send your data anywhere else.
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 | >
131 | );
132 | }
133 |
134 | /** The footer on the bottom of the calendar. */
135 | export function LeftFooter() {
136 | const { state } = useContext(HydrantContext);
137 |
138 | return (
139 |
140 |
147 |
148 | Last updated: {state.lastUpdated}.
149 |
150 |
151 |
152 | Contact
153 |
154 |
155 |
156 | Accessibility
157 |
158 |
159 |
160 |
161 | );
162 | }
163 |
--------------------------------------------------------------------------------
/src/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useRef, useContext } from "react";
2 | import { useSearchParams } from "react-router";
3 |
4 | import {
5 | Card,
6 | IconButton,
7 | Flex,
8 | Image,
9 | Text,
10 | Button,
11 | createListCollection,
12 | } from "@chakra-ui/react";
13 | import {
14 | DialogRoot,
15 | DialogBody,
16 | DialogContent,
17 | DialogFooter,
18 | DialogHeader,
19 | DialogTitle,
20 | DialogTrigger,
21 | DialogActionTrigger,
22 | } from "./ui/dialog";
23 | import { useColorModeValue } from "./ui/color-mode";
24 | import {
25 | SelectContent,
26 | SelectItem,
27 | SelectLabel,
28 | SelectRoot,
29 | SelectTrigger,
30 | SelectValueText,
31 | } from "./ui/select";
32 | import { LuSettings, LuX } from "react-icons/lu";
33 |
34 | import { COLOR_SCHEME_PRESETS } from "../lib/colors";
35 | import type { Preferences } from "../lib/schema";
36 | import { DEFAULT_PREFERENCES } from "../lib/schema";
37 | import { HydrantContext } from "../lib/hydrant";
38 |
39 | import logo from "../assets/logo.svg";
40 | import logoDark from "../assets/logo-dark.svg";
41 | import hydraAnt from "../assets/hydraAnt.png";
42 | import { SIPBLogo } from "./SIPBLogo";
43 |
44 | export function PreferencesDialog() {
45 | const { state, hydrantState } = useContext(HydrantContext);
46 | const { preferences: originalPreferences } = hydrantState;
47 |
48 | const [visible, setVisible] = useState(false);
49 | const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES);
50 | const initialPreferencesRef = useRef(DEFAULT_PREFERENCES);
51 | const initialPreferences = initialPreferencesRef.current;
52 |
53 | const onOpen = () => {
54 | initialPreferencesRef.current = originalPreferences;
55 | setPreferences(originalPreferences);
56 | setVisible(true);
57 | };
58 |
59 | const previewPreferences = (newPreferences: Preferences) => {
60 | setPreferences(newPreferences);
61 | state.setPreferences(newPreferences, false);
62 | };
63 |
64 | const onCancel = () => {
65 | setPreferences(initialPreferences);
66 | state.setPreferences(initialPreferences);
67 | setVisible(false);
68 | };
69 |
70 | const onConfirm = () => {
71 | state.setPreferences(preferences);
72 | setVisible(false);
73 | };
74 |
75 | const collection = createListCollection({
76 | items: [
77 | { label: "System Default", value: "" },
78 | ...COLOR_SCHEME_PRESETS.map(({ name }) => ({
79 | label: name,
80 | value: name,
81 | })),
82 | ],
83 | });
84 |
85 | return (
86 | <>
87 | {
90 | if (e.open) {
91 | onOpen();
92 | } else {
93 | onCancel();
94 | }
95 | }}
96 | >
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 | Preferences
105 |
106 |
107 |
108 | {
112 | if (e.value[0] === "") {
113 | previewPreferences({
114 | ...preferences,
115 | colorScheme: null,
116 | });
117 | return;
118 | }
119 |
120 | const colorScheme = COLOR_SCHEME_PRESETS.find(
121 | ({ name }) => name === e.value[0],
122 | );
123 | if (!colorScheme) return;
124 | previewPreferences({ ...preferences, colorScheme });
125 | }}
126 | >
127 | Color scheme:
128 |
129 |
130 |
131 |
132 | {collection.items.map(({ label, value }) => (
133 |
134 | {label}
135 |
136 | ))}
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 | >
150 | );
151 | }
152 |
153 | /** Header above the left column, with logo and semester selection. */
154 | export function Header() {
155 | const { state } = useContext(HydrantContext);
156 | const logoSrc = useColorModeValue(logo, logoDark);
157 | const [searchParams, setSearchParams] = useSearchParams();
158 |
159 | const urlNameOrig = searchParams.get("ti");
160 | const urlName = searchParams.get("t") ?? state.latestUrlName;
161 |
162 | const [show, setShow] = useState(urlNameOrig !== null);
163 |
164 | const onClose = () => {
165 | setSearchParams((searchParams) => {
166 | searchParams.delete("ti");
167 | return searchParams;
168 | });
169 | setShow(false);
170 | };
171 |
172 | return (
173 |
174 |
182 |
183 |
190 |
191 |
192 |
193 |
194 | {show && (
195 |
196 |
197 |
198 |
199 | Term {urlNameOrig} not found; loaded term {urlName} instead.
200 |
201 |
207 |
208 |
209 |
210 |
211 |
212 | )}
213 |
214 | );
215 | }
216 |
--------------------------------------------------------------------------------
/src/components/MatrixLink.tsx:
--------------------------------------------------------------------------------
1 | import { useContext } from "react";
2 |
3 | import { Class } from "../lib/class";
4 | import { HydrantContext } from "../lib/hydrant";
5 |
6 | import { LuMessageSquare } from "react-icons/lu";
7 | import { Tooltip } from "./ui/tooltip";
8 | import { LinkButton } from "./ui/link-button";
9 |
10 | /** A link to SIPB Matrix's class group chat importer UI */
11 | export function MatrixLink() {
12 | const {
13 | state: { selectedActivities },
14 | } = useContext(HydrantContext);
15 |
16 | // reference: https://github.com/gabrc52/class_group_chats/tree/main/src/routes/import
17 | const matrixLink = `https://matrix.mit.edu/classes/import?via=Hydrant${selectedActivities
18 | .filter((activity) => activity instanceof Class)
19 | .map((cls) => `&class=${cls.number}`)
20 | .join("")}`;
21 |
22 | return (
23 |
24 |
33 |
34 | Join Matrix group chats
35 |
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/src/components/PreregLink.tsx:
--------------------------------------------------------------------------------
1 | import { Class } from "../lib/class";
2 | import { LuClipboardList } from "react-icons/lu";
3 |
4 | import { LinkButton } from "./ui/link-button";
5 | import { Tooltip } from "./ui/tooltip";
6 | import { useContext } from "react";
7 | import { HydrantContext } from "../lib/hydrant";
8 |
9 | /** A link to SIPB Matrix's class group chat importer UI */
10 | export function PreregLink() {
11 | const {
12 | state: { selectedActivities },
13 | } = useContext(HydrantContext);
14 |
15 | // reference: https://github.com/gabrc52/class_group_chats/tree/main/src/routes/import
16 | const preregLink = `https://student.mit.edu/cgi-bin/sfprwtrm.sh?${selectedActivities
17 | .filter((activity) => activity instanceof Class)
18 | .map((cls) => cls.number)
19 | .join(",")}`;
20 |
21 | return (
22 |
23 |
32 |
33 | Pre-register classes
34 |
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/src/components/SIPBLogo.tsx:
--------------------------------------------------------------------------------
1 | import { Image, Link } from "@chakra-ui/react";
2 | import sipbLogo from "../assets/simple-fuzzball.png";
3 |
4 | export function SIPBLogo() {
5 | return (
6 |
14 | by SIPB
15 |
16 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/ScheduleOption.tsx:
--------------------------------------------------------------------------------
1 | import { IconButton, Flex, Text } from "@chakra-ui/react";
2 |
3 | import { CloseButton } from "./ui/close-button";
4 | import { useContext, useState } from "react";
5 |
6 | import { LuArrowLeft, LuArrowRight } from "react-icons/lu";
7 | import { HydrantContext } from "../lib/hydrant";
8 |
9 | export function ScheduleOption() {
10 | const [tooManyOptions, setTooManyOptions] = useState(true);
11 | const { state, hydrantState } = useContext(HydrantContext);
12 | const { selectedOption, totalOptions } = hydrantState;
13 |
14 | return (
15 |
16 |
17 | {
19 | state.selectOption(
20 | (selectedOption - 1 + totalOptions) % totalOptions,
21 | );
22 | }}
23 | size="xs"
24 | variant="ghost"
25 | aria-label="Previous schedule"
26 | >
27 |
28 |
29 | {selectedOption + 1} of {totalOptions}
30 | {
32 | state.selectOption(selectedOption + 1);
33 | }}
34 | size="xs"
35 | variant="ghost"
36 | aria-label="Next schedule"
37 | >
38 |
39 |
40 |
41 | {tooManyOptions && totalOptions > 15 && (
42 |
43 |
44 | Too many options? Use the "Edit sections" button above the class
45 | description.
46 |
47 | {
50 | setTooManyOptions(false);
51 | }}
52 | />
53 |
54 | )}
55 |
56 | );
57 | }
58 |
--------------------------------------------------------------------------------
/src/components/ScheduleSwitcher.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | Flex,
4 | IconButton,
5 | Input,
6 | Link,
7 | createListCollection,
8 | Button,
9 | } from "@chakra-ui/react";
10 | import type { ComponentPropsWithoutRef, ReactNode } from "react";
11 | import { useContext, useEffect, useState } from "react";
12 |
13 | import {
14 | DialogRoot,
15 | DialogBody,
16 | DialogContent,
17 | DialogFooter,
18 | DialogHeader,
19 | DialogTitle,
20 | DialogTrigger,
21 | DialogActionTrigger,
22 | } from "./ui/dialog";
23 | import { MenuContent, MenuItem, MenuRoot, MenuTrigger } from "./ui/menu";
24 | import {
25 | SelectContent,
26 | SelectItem,
27 | SelectRoot,
28 | SelectTrigger,
29 | SelectValueText,
30 | SelectLabel,
31 | } from "./ui/select";
32 |
33 | import type { Save } from "../lib/schema";
34 | import { HydrantContext } from "../lib/hydrant";
35 |
36 | import {
37 | LuCopy,
38 | LuEllipsis,
39 | LuFilePlus2,
40 | LuPencilLine,
41 | LuPin,
42 | LuPinOff,
43 | LuSave,
44 | LuShare2,
45 | LuTrash2,
46 | } from "react-icons/lu";
47 |
48 | import useCopyToClipboard from "react-use/lib/useCopyToClipboard.js";
49 |
50 | function SmallButton(props: ComponentPropsWithoutRef<"button">) {
51 | const { children, ...otherProps } = props;
52 | return (
53 |
56 | );
57 | }
58 |
59 | function SelectWithWarn(props: { saveId: string; saves: Save[] }) {
60 | const { saveId, saves } = props;
61 | const { state } = useContext(HydrantContext);
62 | const [confirmSave, setConfirmSave] = useState("");
63 | const confirmName = saves.find((save) => save.id === confirmSave)?.name;
64 | const defaultScheduleId = state.defaultSchedule;
65 |
66 | const formatScheduleName = (id: string, name: string) => {
67 | return id === defaultScheduleId ? `${name} (default)` : name;
68 | };
69 |
70 | const scheduleCollection = createListCollection({
71 | items: [
72 | { label: "Not saved", value: "" },
73 | ...saves.map(({ id, name }) => ({
74 | label: formatScheduleName(id, name),
75 | value: id,
76 | })),
77 | ],
78 | });
79 |
80 | return (
81 | <>
82 | {
90 | if (!saveId) {
91 | setConfirmSave(e.value[0]);
92 | } else {
93 | state.loadSave(e.value[0]);
94 | }
95 | }}
96 | >
97 | Select schedule
98 |
99 |
100 |
101 |
102 | {scheduleCollection.items.map(({ label, value }) =>
103 | value != "" ? (
104 |
105 | {label}
106 |
107 | ) : null,
108 | )}
109 |
110 |
111 | {
114 | if (!e.open) {
115 | setConfirmSave("");
116 | }
117 | }}
118 | >
119 |
120 |
121 | Are you sure?
122 |
123 |
124 | The current schedule is loaded from a URL and is not saved. Are you
125 | sure you want to load schedule {confirmName} without saving your
126 | current schedule?
127 |
128 |
129 |
130 |
131 |
132 |
140 |
141 |
142 |
143 | >
144 | );
145 | }
146 |
147 | function DeleteDialog(props: {
148 | saveId: string;
149 | name: string;
150 | children: ReactNode;
151 | }) {
152 | const { saveId, name, children } = props;
153 | const { state } = useContext(HydrantContext);
154 | const [show, setShow] = useState(false);
155 |
156 | return (
157 | {
160 | setShow(e.open);
161 | }}
162 | role="alertdialog"
163 | >
164 | {children}
165 |
166 |
167 | Are you sure?
168 |
169 | Are you sure you want to delete {name}?
170 |
171 |
172 |
173 |
174 |
184 |
185 |
186 |
187 | );
188 | }
189 |
190 | function ExportDialog(props: { children: ReactNode }) {
191 | const { children } = props;
192 | const { state } = useContext(HydrantContext);
193 | const [show, setShow] = useState(false);
194 | const link = state.urlify();
195 | const [clipboardState, copyToClipboard] = useCopyToClipboard();
196 |
197 | return (
198 | {
201 | setShow(e.open);
202 | }}
203 | >
204 | {children}
205 |
206 |
207 | Share schedule
208 |
209 |
210 | Share the following link:
211 |
212 |
213 | {link}
214 |
215 |
216 |
217 |
218 |
219 |
220 |
227 |
228 |
229 |
230 | );
231 | }
232 |
233 | export function ScheduleSwitcher() {
234 | const { state, hydrantState } = useContext(HydrantContext);
235 | const { saves, saveId } = hydrantState;
236 |
237 | const currentName = saves.find((save) => save.id === saveId)?.name ?? "";
238 | const [isRenaming, setIsRenaming] = useState(false);
239 | const [name, setName] = useState(currentName);
240 | const defaultScheduleId = state.defaultSchedule;
241 |
242 | useEffect(() => {
243 | setName(saves.find((save) => save.id === saveId)?.name ?? "");
244 | }, [saves, saveId]);
245 |
246 | const [renderHeading, renderButtons] = (() => {
247 | if (isRenaming) {
248 | const renderHeading = () => (
249 | {
252 | setName(e.target.value);
253 | }}
254 | autoFocus
255 | onKeyUp={(e) => {
256 | if (e.key === "Enter") {
257 | onConfirm();
258 | } else if (e.key === "Escape") {
259 | onCancel();
260 | }
261 | }}
262 | placeholder="New Schedule"
263 | size="sm"
264 | width="fit-content"
265 | />
266 | );
267 | const onConfirm = () => {
268 | state.renameSave(saveId, name);
269 | setIsRenaming(false);
270 | };
271 | const onCancel = () => {
272 | setName(currentName);
273 | setIsRenaming(false);
274 | };
275 | const renderButtons = () => (
276 | <>
277 | Confirm
278 | Cancel
279 | >
280 | );
281 | return [renderHeading, renderButtons];
282 | }
283 |
284 | const renderHeading = () => (
285 |
286 | );
287 | const onRename = () => {
288 | setIsRenaming(true);
289 | };
290 | const onSave = () => {
291 | state.addSave(Boolean(saveId));
292 | };
293 | const onCopy = () => {
294 | state.addSave(false, `${currentName} copy`);
295 | };
296 | const renderButtons = () => (
297 |
298 |
299 |
300 |
301 |
302 |
303 |
304 | {saveId && (
305 |
309 | )}
310 |
314 | {saveId && (
315 | save.id === saveId)?.name ?? ""}
318 | >
319 |
327 |
328 | )}
329 |
342 | {saveId && (
343 |
362 | )}
363 |
364 |
368 |
369 |
370 |
371 | );
372 | return [renderHeading, renderButtons];
373 | })();
374 |
375 | return (
376 |
377 | {renderHeading()}
378 | {renderButtons()}
379 |
380 | );
381 | }
382 |
--------------------------------------------------------------------------------
/src/components/SelectedActivities.tsx:
--------------------------------------------------------------------------------
1 | import { Flex, Text, Button, ButtonGroup } from "@chakra-ui/react";
2 | import { useContext, type ComponentPropsWithoutRef } from "react";
3 |
4 | import type { Activity } from "../lib/activity";
5 | import { textColor } from "../lib/colors";
6 | import { Class } from "../lib/class";
7 | import { HydrantContext } from "../lib/hydrant";
8 |
9 | import { LuPlus } from "react-icons/lu";
10 |
11 | export function ColorButton(
12 | props: ComponentPropsWithoutRef<"button"> & { color: string },
13 | ) {
14 | const { children, color, style, ...otherProps } = props;
15 | return (
16 |
27 | );
28 | }
29 |
30 | /** A button representing a single, selected activity. */
31 | function ActivityButton(props: { activity: Activity }) {
32 | const { activity } = props;
33 | const { state } = useContext(HydrantContext);
34 | const color = activity.backgroundColor;
35 | return (
36 | {
39 | state.setViewedActivity(activity);
40 | }}
41 | onDoubleClick={() => {
42 | state.removeActivity(activity);
43 | }}
44 | >
45 | {activity.buttonName}
46 |
47 | );
48 | }
49 |
50 | /** List of selected activities; one button for each activity. */
51 | export function SelectedActivities() {
52 | const { state, hydrantState } = useContext(HydrantContext);
53 | const { selectedActivities, units, hours, warnings } = hydrantState;
54 |
55 | return (
56 |
57 |
58 | {units} units
59 | {hours.toFixed(1)} hours
60 |
61 |
62 | {selectedActivities.map((activity) => (
63 |
67 | ))}
68 |
76 |
77 | {warnings.map((warning) => (
78 |
79 | {warning}
80 |
81 | ))}
82 |
83 | );
84 | }
85 |
--------------------------------------------------------------------------------
/src/components/TermSwitcher.tsx:
--------------------------------------------------------------------------------
1 | import { useContext } from "react";
2 |
3 | import {
4 | SelectContent,
5 | SelectItem,
6 | SelectLabel,
7 | SelectRoot,
8 | SelectTrigger,
9 | SelectValueText,
10 | } from "./ui/select";
11 | import { createListCollection } from "@chakra-ui/react";
12 |
13 | import { Term, toFullUrl, getUrlNames } from "../lib/dates";
14 | import { HydrantContext } from "../lib/hydrant";
15 |
16 | export function TermSwitcher() {
17 | const { state } = useContext(HydrantContext);
18 | const toUrl = (urlName: string) => toFullUrl(urlName, state.latestUrlName);
19 | const defaultValue = toUrl(state.term.urlName);
20 |
21 | const urlOptions = createListCollection({
22 | items: getUrlNames(state.latestUrlName).map((urlName) => {
23 | const { niceName } = new Term({ urlName });
24 | return {
25 | label: niceName,
26 | value: toUrl(urlName),
27 | };
28 | }),
29 | });
30 |
31 | return (
32 | {
36 | window.location.href = e.value[0];
37 | }}
38 | size="sm"
39 | w="8rem"
40 | >
41 | Select semester
42 |
43 |
44 |
45 |
46 | {urlOptions.items.map(({ label, value }) => {
47 | return (
48 |
49 | {label}
50 |
51 | );
52 | })}
53 |
54 |
55 | );
56 | }
57 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import type { Tooltip as ChakraTooltip } from "@chakra-ui/react";
2 | import { Button, type ButtonProps } from "@chakra-ui/react";
3 | import { Tooltip } from "./tooltip";
4 | import type { RefObject } from "react";
5 |
6 | export interface LabelledButtonProps extends ButtonProps {
7 | showArrow?: boolean;
8 | portalled?: boolean;
9 | portalRef?: RefObject;
10 | titleProps?: ChakraTooltip.ContentProps;
11 | disabled?: boolean;
12 | }
13 |
14 | export const LabelledButton = (props: LabelledButtonProps) => {
15 | const { showArrow, title, titleProps, portalled, disabled, ...rest } = props;
16 | if (!title) return ;
17 | return (
18 |
25 |
26 |
27 | );
28 | };
29 |
--------------------------------------------------------------------------------
/src/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | import { Checkbox as ChakraCheckbox } from "@chakra-ui/react";
2 | import type { ReactNode, InputHTMLAttributes, Ref } from "react";
3 | import { forwardRef } from "react";
4 |
5 | export interface CheckboxProps extends ChakraCheckbox.RootProps {
6 | icon?: ReactNode;
7 | inputProps?: InputHTMLAttributes;
8 | rootRef?: Ref;
9 | }
10 |
11 | export const Checkbox = forwardRef(
12 | function Checkbox(props, ref) {
13 | const { icon, children, inputProps, rootRef, ...rest } = props;
14 | return (
15 |
16 |
17 |
18 | {/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */}
19 | {icon || }
20 |
21 | {children != null && (
22 | {children}
23 | )}
24 |
25 | );
26 | },
27 | );
28 |
--------------------------------------------------------------------------------
/src/components/ui/close-button.tsx:
--------------------------------------------------------------------------------
1 | import type { ButtonProps } from "@chakra-ui/react";
2 | import { IconButton as ChakraIconButton } from "@chakra-ui/react";
3 | import { forwardRef } from "react";
4 | import { LuX } from "react-icons/lu";
5 |
6 | export type CloseButtonProps = ButtonProps;
7 |
8 | export const CloseButton = forwardRef(
9 | function CloseButton(props, ref) {
10 | return (
11 |
12 | {props.children ?? }
13 |
14 | );
15 | },
16 | );
17 |
--------------------------------------------------------------------------------
/src/components/ui/color-mode.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import type { IconButtonProps, SpanProps } from "@chakra-ui/react";
4 | import { ClientOnly, IconButton, Skeleton, Span } from "@chakra-ui/react";
5 | import { ThemeProvider, useTheme } from "next-themes";
6 | import type { ThemeProviderProps } from "next-themes";
7 | import { forwardRef } from "react";
8 | import { LuMoon, LuSun } from "react-icons/lu";
9 |
10 | export type ColorModeProviderProps = ThemeProviderProps;
11 |
12 | export function ColorModeProvider(props: ColorModeProviderProps) {
13 | return (
14 |
15 | );
16 | }
17 |
18 | export type ColorMode = "light" | "dark";
19 |
20 | export interface UseColorModeReturn {
21 | colorMode: ColorMode;
22 | setColorMode: (colorMode: ColorMode) => void;
23 | toggleColorMode: () => void;
24 | }
25 |
26 | // eslint-disable-next-line react-refresh/only-export-components
27 | export function useColorMode(): UseColorModeReturn {
28 | const { resolvedTheme, setTheme } = useTheme();
29 | const toggleColorMode = () => {
30 | setTheme(resolvedTheme === "dark" ? "light" : "dark");
31 | };
32 | return {
33 | colorMode: resolvedTheme as ColorMode,
34 | setColorMode: setTheme,
35 | toggleColorMode,
36 | };
37 | }
38 |
39 | // eslint-disable-next-line react-refresh/only-export-components
40 | export function useColorModeValue(light: T, dark: T) {
41 | const { colorMode } = useColorMode();
42 | return colorMode === "dark" ? dark : light;
43 | }
44 |
45 | export function ColorModeIcon() {
46 | const { colorMode } = useColorMode();
47 | return colorMode === "dark" ? : ;
48 | }
49 |
50 | type ColorModeButtonProps = Omit;
51 |
52 | export const ColorModeButton = forwardRef<
53 | HTMLButtonElement,
54 | ColorModeButtonProps
55 | >(function ColorModeButton(props, ref) {
56 | const { toggleColorMode } = useColorMode();
57 | return (
58 | }>
59 |
73 |
74 |
75 |
76 | );
77 | });
78 |
79 | export const LightMode = forwardRef(
80 | function LightMode(props, ref) {
81 | return (
82 |
91 | );
92 | },
93 | );
94 |
95 | export const DarkMode = forwardRef(
96 | function DarkMode(props, ref) {
97 | return (
98 |
107 | );
108 | },
109 | );
110 |
--------------------------------------------------------------------------------
/src/components/ui/color-picker.tsx:
--------------------------------------------------------------------------------
1 | import type { IconButtonProps, StackProps } from "@chakra-ui/react";
2 | import {
3 | ColorPicker as ChakraColorPicker,
4 | For,
5 | IconButton,
6 | Portal,
7 | Span,
8 | Stack,
9 | Text,
10 | VStack,
11 | } from "@chakra-ui/react";
12 | import type { RefObject } from "react";
13 | import { forwardRef } from "react";
14 | import { LuCheck, LuPipette } from "react-icons/lu";
15 |
16 | export const ColorPickerTrigger = forwardRef<
17 | HTMLButtonElement,
18 | ChakraColorPicker.TriggerProps & { fitContent?: boolean }
19 | >(function ColorPickerTrigger(props, ref) {
20 | const { fitContent, ...rest } = props;
21 | return (
22 |
28 | {/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */}
29 | {props.children || }
30 |
31 | );
32 | });
33 |
34 | export const ColorPickerInput = forwardRef<
35 | HTMLInputElement,
36 | Omit
37 | >(function ColorHexInput(props, ref) {
38 | return ;
39 | });
40 |
41 | interface ColorPickerContentProps extends ChakraColorPicker.ContentProps {
42 | portalled?: boolean;
43 | portalRef?: RefObject;
44 | }
45 |
46 | export const ColorPickerContent = forwardRef<
47 | HTMLDivElement,
48 | ColorPickerContentProps
49 | >(function ColorPickerContent(props, ref) {
50 | const { portalled = true, portalRef, ...rest } = props;
51 | return (
52 |
53 |
54 |
55 |
56 |
57 | );
58 | });
59 |
60 | export const ColorPickerInlineContent = forwardRef<
61 | HTMLDivElement,
62 | ChakraColorPicker.ContentProps
63 | >(function ColorPickerInlineContent(props, ref) {
64 | return (
65 |
72 | );
73 | });
74 |
75 | export const ColorPickerSliders = forwardRef(
76 | function ColorPickerSliders(props, ref) {
77 | return (
78 |
79 |
80 |
81 |
82 | );
83 | },
84 | );
85 |
86 | export const ColorPickerArea = forwardRef<
87 | HTMLDivElement,
88 | ChakraColorPicker.AreaProps
89 | >(function ColorPickerArea(props, ref) {
90 | return (
91 |
92 |
93 |
94 |
95 | );
96 | });
97 |
98 | export const ColorPickerEyeDropper = forwardRef<
99 | HTMLButtonElement,
100 | IconButtonProps
101 | >(function ColorPickerEyeDropper(props, ref) {
102 | return (
103 |
104 |
105 |
106 |
107 |
108 | );
109 | });
110 |
111 | export const ColorPickerChannelSlider = forwardRef<
112 | HTMLDivElement,
113 | ChakraColorPicker.ChannelSliderProps
114 | >(function ColorPickerSlider(props, ref) {
115 | return (
116 |
117 |
118 |
119 |
120 |
121 | );
122 | });
123 |
124 | export const ColorPickerSwatchTrigger = forwardRef<
125 | HTMLButtonElement,
126 | ChakraColorPicker.SwatchTriggerProps & {
127 | swatchSize?: ChakraColorPicker.SwatchTriggerProps["boxSize"];
128 | }
129 | >(function ColorPickerSwatchTrigger(props, ref) {
130 | const { swatchSize, children, ...rest } = props;
131 | return (
132 |
137 | {/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */}
138 | {children || (
139 |
140 |
141 |
142 |
143 |
144 | )}
145 |
146 | );
147 | });
148 |
149 | export const ColorPickerRoot = forwardRef<
150 | HTMLDivElement,
151 | ChakraColorPicker.RootProps
152 | >(function ColorPickerRoot(props, ref) {
153 | return (
154 |
155 | {props.children}
156 |
157 |
158 | );
159 | });
160 |
161 | const formatMap = {
162 | rgba: ["red", "green", "blue", "alpha"],
163 | hsla: ["hue", "saturation", "lightness", "alpha"],
164 | hsba: ["hue", "saturation", "brightness", "alpha"],
165 | hexa: ["hex", "alpha"],
166 | } as const;
167 |
168 | export const ColorPickerChannelInputs = forwardRef<
169 | HTMLDivElement,
170 | ChakraColorPicker.ViewProps
171 | >(function ColorPickerChannelInputs(props, ref) {
172 | const channels = formatMap[props.format];
173 | return (
174 |
175 | {channels.map((channel) => (
176 |
177 |
184 |
185 | {channel.charAt(0).toUpperCase()}
186 |
187 |
188 | ))}
189 |
190 | );
191 | });
192 |
193 | export const ColorPickerChannelSliders = forwardRef<
194 | HTMLDivElement,
195 | ChakraColorPicker.ViewProps
196 | >(function ColorPickerChannelSliders(props, ref) {
197 | const channels = formatMap[props.format];
198 | return (
199 |
200 |
201 | {(channel) => (
202 |
203 |
209 | {channel}
210 |
211 |
212 |
213 | )}
214 |
215 |
216 | );
217 | });
218 |
219 | export const ColorPickerLabel = ChakraColorPicker.Label;
220 | export const ColorPickerControl = ChakraColorPicker.Control;
221 | export const ColorPickerValueText = ChakraColorPicker.ValueText;
222 | export const ColorPickerValueSwatch = ChakraColorPicker.ValueSwatch;
223 | export const ColorPickerChannelInput = ChakraColorPicker.ChannelInput;
224 | export const ColorPickerSwatchGroup = ChakraColorPicker.SwatchGroup;
225 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | import { Dialog as ChakraDialog, Portal } from "@chakra-ui/react";
2 | import { CloseButton } from "./close-button";
3 | import type { RefObject } from "react";
4 | import { forwardRef } from "react";
5 |
6 | interface DialogContentProps extends ChakraDialog.ContentProps {
7 | portalled?: boolean;
8 | portalRef?: RefObject;
9 | backdrop?: boolean;
10 | }
11 |
12 | export const DialogContent = forwardRef(
13 | function DialogContent(props, ref) {
14 | const {
15 | children,
16 | portalled = true,
17 | portalRef,
18 | backdrop = true,
19 | ...rest
20 | } = props;
21 |
22 | return (
23 |
24 | {backdrop && }
25 |
26 |
27 | {children}
28 |
29 |
30 |
31 | );
32 | },
33 | );
34 |
35 | export const DialogCloseTrigger = forwardRef<
36 | HTMLButtonElement,
37 | ChakraDialog.CloseTriggerProps
38 | >(function DialogCloseTrigger(props, ref) {
39 | return (
40 |
47 |
48 | {props.children}
49 |
50 |
51 | );
52 | });
53 |
54 | export const DialogRoot = ChakraDialog.Root;
55 | export const DialogFooter = ChakraDialog.Footer;
56 | export const DialogHeader = ChakraDialog.Header;
57 | export const DialogBody = ChakraDialog.Body;
58 | export const DialogBackdrop = ChakraDialog.Backdrop;
59 | export const DialogTitle = ChakraDialog.Title;
60 | export const DialogDescription = ChakraDialog.Description;
61 | export const DialogTrigger = ChakraDialog.Trigger;
62 | export const DialogActionTrigger = ChakraDialog.ActionTrigger;
63 |
--------------------------------------------------------------------------------
/src/components/ui/field.tsx:
--------------------------------------------------------------------------------
1 | import { Field as ChakraField } from "@chakra-ui/react";
2 | import type { ReactNode } from "react";
3 | import { forwardRef } from "react";
4 |
5 | export interface FieldProps extends Omit {
6 | label?: ReactNode;
7 | helperText?: ReactNode;
8 | errorText?: ReactNode;
9 | optionalText?: ReactNode;
10 | }
11 |
12 | export const Field = forwardRef(
13 | function Field(props, ref) {
14 | const { label, children, helperText, errorText, optionalText, ...rest } =
15 | props;
16 | return (
17 |
18 | {label && (
19 |
20 | {label}
21 |
22 |
23 | )}
24 | {children}
25 | {helperText && (
26 | {helperText}
27 | )}
28 | {errorText && (
29 | {errorText}
30 | )}
31 |
32 | );
33 | },
34 | );
35 |
--------------------------------------------------------------------------------
/src/components/ui/input-group.tsx:
--------------------------------------------------------------------------------
1 | import type { BoxProps, InputElementProps } from "@chakra-ui/react";
2 | import { Group, InputElement } from "@chakra-ui/react";
3 | import type { ReactNode, ReactElement } from "react";
4 | import { forwardRef, Children, cloneElement } from "react";
5 |
6 | export interface InputGroupProps extends BoxProps {
7 | startElementProps?: InputElementProps;
8 | endElementProps?: InputElementProps;
9 | startElement?: ReactNode;
10 | endElement?: ReactNode;
11 | children: ReactElement;
12 | startOffset?: InputElementProps["paddingStart"];
13 | endOffset?: InputElementProps["paddingEnd"];
14 | }
15 |
16 | export const InputGroup = forwardRef(
17 | function InputGroup(props, ref) {
18 | const {
19 | startElement,
20 | startElementProps,
21 | endElement,
22 | endElementProps,
23 | children,
24 | startOffset = "6px",
25 | endOffset = "6px",
26 | ...rest
27 | } = props;
28 |
29 | const child = Children.only>(children);
30 |
31 | return (
32 |
33 | {startElement && (
34 |
35 | {startElement}
36 |
37 | )}
38 | {cloneElement(child, {
39 | ...(startElement && {
40 | ps: `calc(var(--input-height) - ${startOffset as string})`,
41 | }),
42 | ...(endElement && {
43 | pe: `calc(var(--input-height) - ${endOffset as string})`,
44 | }),
45 | ...children.props,
46 | })}
47 | {endElement && (
48 |
49 | {endElement}
50 |
51 | )}
52 |
53 | );
54 | },
55 | );
56 |
--------------------------------------------------------------------------------
/src/components/ui/link-button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import type { HTMLChakraProps, RecipeProps } from "@chakra-ui/react";
4 | import { createRecipeContext } from "@chakra-ui/react";
5 |
6 | export type LinkButtonProps = HTMLChakraProps<"a", RecipeProps<"button">>;
7 |
8 | const { withContext } = createRecipeContext({ key: "button" });
9 |
10 | // Replace "a" with your framework's link component
11 | export const LinkButton = withContext("a");
12 |
--------------------------------------------------------------------------------
/src/components/ui/menu.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { AbsoluteCenter, Menu as ChakraMenu, Portal } from "@chakra-ui/react";
4 | import type { RefObject, ReactNode } from "react";
5 | import { forwardRef } from "react";
6 | import { LuCheck, LuChevronRight } from "react-icons/lu";
7 |
8 | interface MenuContentProps extends ChakraMenu.ContentProps {
9 | portalled?: boolean;
10 | portalRef?: RefObject;
11 | }
12 |
13 | export const MenuContent = forwardRef(
14 | function MenuContent(props, ref) {
15 | const { portalled = true, portalRef, ...rest } = props;
16 | return (
17 |
18 |
19 |
20 |
21 |
22 | );
23 | },
24 | );
25 |
26 | export const MenuArrow = forwardRef(
27 | function MenuArrow(props, ref) {
28 | return (
29 |
30 |
31 |
32 | );
33 | },
34 | );
35 |
36 | export const MenuCheckboxItem = forwardRef<
37 | HTMLDivElement,
38 | ChakraMenu.CheckboxItemProps
39 | >(function MenuCheckboxItem(props, ref) {
40 | return (
41 |
42 |
43 |
44 |
45 |
46 |
47 | {props.children}
48 |
49 | );
50 | });
51 |
52 | export const MenuRadioItem = forwardRef<
53 | HTMLDivElement,
54 | ChakraMenu.RadioItemProps
55 | >(function MenuRadioItem(props, ref) {
56 | const { children, ...rest } = props;
57 | return (
58 |
59 |
60 |
61 |
62 |
63 |
64 | {children}
65 |
66 | );
67 | });
68 |
69 | export const MenuItemGroup = forwardRef<
70 | HTMLDivElement,
71 | ChakraMenu.ItemGroupProps
72 | >(function MenuItemGroup(props, ref) {
73 | const { title, children, ...rest } = props;
74 | return (
75 |
76 | {title && (
77 |
78 | {title}
79 |
80 | )}
81 | {children}
82 |
83 | );
84 | });
85 |
86 | export interface MenuTriggerItemProps extends ChakraMenu.ItemProps {
87 | startIcon?: ReactNode;
88 | }
89 |
90 | export const MenuTriggerItem = forwardRef(
91 | function MenuTriggerItem(props, ref) {
92 | const { startIcon, children, ...rest } = props;
93 | return (
94 |
95 | {startIcon}
96 | {children}
97 |
98 |
99 | );
100 | },
101 | );
102 |
103 | export const MenuRadioItemGroup = ChakraMenu.RadioItemGroup;
104 | export const MenuContextTrigger = ChakraMenu.ContextTrigger;
105 | export const MenuRoot = ChakraMenu.Root;
106 | export const MenuSeparator = ChakraMenu.Separator;
107 |
108 | export const MenuItem = ChakraMenu.Item;
109 | export const MenuItemText = ChakraMenu.ItemText;
110 | export const MenuItemCommand = ChakraMenu.ItemCommand;
111 | export const MenuTrigger = ChakraMenu.Trigger;
112 |
--------------------------------------------------------------------------------
/src/components/ui/provider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ChakraProvider, createSystem, defaultConfig } from "@chakra-ui/react";
4 | import { ColorModeProvider, type ColorModeProviderProps } from "./color-mode";
5 |
6 | const system = createSystem(defaultConfig, {
7 | theme: {
8 | tokens: {
9 | fonts: {
10 | heading: { value: `'Inter Variable', sans-serif` },
11 | body: { value: `'Inter Variable', sans-serif` },
12 | },
13 | },
14 | semanticTokens: {
15 | radii: {
16 | l1: { value: "{radii.sm}" },
17 | l2: { value: "{radii.md}" },
18 | l3: { value: "{radii.lg}" },
19 | },
20 | },
21 | recipes: {
22 | button: {
23 | base: {
24 | fontWeight: "semibold",
25 | },
26 | defaultVariants: {
27 | // @ts-expect-error: this works I promise :(
28 | variant: "subtle",
29 | },
30 | },
31 | },
32 | },
33 | });
34 |
35 | export function Provider(props: ColorModeProviderProps) {
36 | return (
37 |
38 |
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/src/components/ui/radio.tsx:
--------------------------------------------------------------------------------
1 | import { RadioGroup as ChakraRadioGroup } from "@chakra-ui/react";
2 | import type { Ref, InputHTMLAttributes } from "react";
3 | import { forwardRef } from "react";
4 |
5 | export interface RadioProps extends ChakraRadioGroup.ItemProps {
6 | rootRef?: Ref;
7 | inputProps?: InputHTMLAttributes;
8 | }
9 |
10 | export const Radio = forwardRef(
11 | function Radio(props, ref) {
12 | const { children, inputProps, rootRef, ...rest } = props;
13 | return (
14 |
15 |
16 |
17 | {children && (
18 | {children}
19 | )}
20 |
21 | );
22 | },
23 | );
24 |
25 | export const RadioGroup = ChakraRadioGroup.Root;
26 |
--------------------------------------------------------------------------------
/src/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import type { CollectionItem } from "@chakra-ui/react";
4 | import { Select as ChakraSelect, Portal } from "@chakra-ui/react";
5 | import { CloseButton } from "./close-button";
6 | import type { RefObject, ReactNode } from "react";
7 | import { forwardRef } from "react";
8 |
9 | interface SelectTriggerProps extends ChakraSelect.ControlProps {
10 | clearable?: boolean;
11 | }
12 |
13 | export const SelectTrigger = forwardRef(
14 | function SelectTrigger(props, ref) {
15 | const { children, clearable, ...rest } = props;
16 | return (
17 |
18 | {children}
19 |
20 | {clearable && }
21 |
22 |
23 |
24 | );
25 | },
26 | );
27 |
28 | const SelectClearTrigger = forwardRef<
29 | HTMLButtonElement,
30 | ChakraSelect.ClearTriggerProps
31 | >(function SelectClearTrigger(props, ref) {
32 | return (
33 |
34 |
41 |
42 | );
43 | });
44 |
45 | interface SelectContentProps extends ChakraSelect.ContentProps {
46 | portalled?: boolean;
47 | portalRef?: RefObject;
48 | }
49 |
50 | export const SelectContent = forwardRef(
51 | function SelectContent(props, ref) {
52 | const { portalled = true, portalRef, ...rest } = props;
53 | return (
54 |
55 |
56 |
57 |
58 |
59 | );
60 | },
61 | );
62 |
63 | export const SelectItem = forwardRef(
64 | function SelectItem(props, ref) {
65 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
66 | const { item, children, ...rest } = props;
67 | return (
68 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
69 |
70 | {children}
71 |
72 |
73 | );
74 | },
75 | );
76 |
77 | interface SelectValueTextProps
78 | extends Omit {
79 | children?(items: CollectionItem[]): ReactNode;
80 | }
81 |
82 | export const SelectValueText = forwardRef<
83 | HTMLSpanElement,
84 | SelectValueTextProps
85 | >(function SelectValueText(props, ref) {
86 | // eslint-disable-next-line @typescript-eslint/unbound-method
87 | const { children, ...rest } = props;
88 | return (
89 |
90 |
91 | {(select) => {
92 | const items = select.selectedItems;
93 | if (items.length === 0) return props.placeholder;
94 | if (children) return children(items);
95 | if (items.length === 1)
96 | return select.collection.stringifyItem(items[0]);
97 | return `${items.length.toString()} selected`;
98 | }}
99 |
100 |
101 | );
102 | });
103 |
104 | export const SelectRoot = forwardRef(
105 | function SelectRoot(props, ref) {
106 | return (
107 |
112 | {props.asChild ? (
113 | props.children
114 | ) : (
115 | <>
116 |
117 | {props.children}
118 | >
119 | )}
120 |
121 | );
122 | },
123 | ) as ChakraSelect.RootComponent;
124 |
125 | interface SelectItemGroupProps extends ChakraSelect.ItemGroupProps {
126 | label: ReactNode;
127 | }
128 |
129 | export const SelectItemGroup = forwardRef(
130 | function SelectItemGroup(props, ref) {
131 | const { children, label, ...rest } = props;
132 | return (
133 |
134 | {label}
135 | {children}
136 |
137 | );
138 | },
139 | );
140 |
141 | export const SelectLabel = ChakraSelect.Label;
142 | export const SelectItemText = ChakraSelect.ItemText;
143 |
--------------------------------------------------------------------------------
/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | import { Tooltip as ChakraTooltip, Portal } from "@chakra-ui/react";
2 | import type { RefObject, ReactNode } from "react";
3 | import { forwardRef } from "react";
4 |
5 | export interface TooltipProps extends ChakraTooltip.RootProps {
6 | showArrow?: boolean;
7 | portalled?: boolean;
8 | portalRef?: RefObject;
9 | content: ReactNode;
10 | contentProps?: ChakraTooltip.ContentProps;
11 | disabled?: boolean;
12 | }
13 |
14 | export const Tooltip = forwardRef(
15 | function Tooltip(props, ref) {
16 | const {
17 | showArrow,
18 | children,
19 | disabled,
20 | portalled = true,
21 | content,
22 | contentProps,
23 | portalRef,
24 | openDelay = 0,
25 | closeDelay = 0,
26 | ...rest
27 | } = props;
28 |
29 | if (disabled) return children;
30 |
31 | return (
32 |
37 | {children}
38 |
39 |
40 |
41 | {showArrow && (
42 |
43 |
44 |
45 | )}
46 | {content}
47 |
48 |
49 |
50 |
51 | );
52 | },
53 | );
54 |
--------------------------------------------------------------------------------
/src/entry.client.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import { HydratedRouter } from "react-router/dom";
4 |
5 | ReactDOM.hydrateRoot(
6 | document,
7 |
8 |
9 | ,
10 | );
11 |
--------------------------------------------------------------------------------
/src/lib/activity.ts:
--------------------------------------------------------------------------------
1 | import type { EventInput } from "@fullcalendar/core";
2 | import { nanoid } from "nanoid";
3 |
4 | import type { Class } from "./class";
5 | import type { ColorScheme } from "./colors";
6 | import { fallbackColor, textColor } from "./colors";
7 | import { Slot } from "./dates";
8 | import type { RawTimeslot } from "./rawClass";
9 | import { sum } from "./utils";
10 |
11 | /** A period of time, spanning several Slots. */
12 | export class Timeslot {
13 | startSlot: Slot;
14 | numSlots: number;
15 |
16 | constructor(startSlot: number, numSlots: number) {
17 | this.startSlot = Slot.fromSlotNumber(startSlot);
18 | this.numSlots = numSlots;
19 | }
20 |
21 | /** Construct a timeslot from [startSlot, endSlot). */
22 | static fromStartEnd(startSlot: Slot, endSlot: Slot): Timeslot {
23 | return new Timeslot(startSlot.slot, endSlot.slot - startSlot.slot);
24 | }
25 |
26 | /** The first slot after this Timeslot, or the exclusive end slot. */
27 | get endSlot(): Slot {
28 | return this.startSlot.add(this.numSlots);
29 | }
30 |
31 | /** The start time, on the week of 2001-01-01. */
32 | get startTime(): Date {
33 | return this.startSlot.startDate;
34 | }
35 |
36 | /** The end time, on the week of 2001-01-01. */
37 | get endTime(): Date {
38 | return this.endSlot.startDate;
39 | }
40 |
41 | /** The number of hours this timeslot spans. */
42 | get hours(): number {
43 | return this.numSlots / 2;
44 | }
45 |
46 | /**
47 | * @param other - timeslot to compare to
48 | * @returns True if this timeslot conflicts with the other timeslot
49 | */
50 | conflicts(other: Timeslot): boolean {
51 | return (
52 | this.startSlot.slot < other.endSlot.slot &&
53 | other.startSlot.slot < this.endSlot.slot
54 | );
55 | }
56 |
57 | /** Convert to string of the form "Mon, 9:30 AM – 11:00 AM". */
58 | toString(): string {
59 | return `${this.startSlot.dayString}, ${this.startSlot.timeString} – ${this.endSlot.timeString}`;
60 | }
61 |
62 | /** @returns True if this timeslot is equal to other timeslot */
63 | equals(other: Timeslot): boolean {
64 | return this.startSlot === other.startSlot && this.endSlot === other.endSlot;
65 | }
66 | }
67 |
68 | /**
69 | * A group of events to be rendered in a calendar, all of the same name, room,
70 | * and color.
71 | */
72 | export class Event {
73 | /** The parent activity owning the event. */
74 | activity: Activity;
75 | /** The name of the event. */
76 | name: string;
77 | /** All slots of the event. */
78 | slots: Timeslot[];
79 | /** The room of the event. */
80 | room: string | undefined;
81 | /** If defined, 1 -> first half; 2 -> second half. */
82 | half: number | undefined;
83 |
84 | constructor(
85 | activity: Activity,
86 | name: string,
87 | slots: Timeslot[],
88 | room: string | undefined = undefined,
89 | half: number | undefined = undefined,
90 | ) {
91 | this.activity = activity;
92 | this.name = name;
93 | this.slots = slots;
94 | this.room = room;
95 | this.half = half;
96 | }
97 |
98 | /** List of events that can be directly given to FullCalendar. */
99 | get eventInputs(): EventInput[] {
100 | const color = this.activity.backgroundColor;
101 | return this.slots.map((slot) => ({
102 | textColor: textColor(color),
103 | title: this.name,
104 | start: slot.startTime,
105 | end: slot.endTime,
106 | backgroundColor: color,
107 | borderColor: color,
108 | room: this.room,
109 | activity: this.activity,
110 | }));
111 | }
112 | }
113 |
114 | /** A non-class activity. */
115 | export class NonClass {
116 | /** ID unique over all Activities. */
117 | readonly id: string;
118 | name = "New Activity";
119 | /** The background color for the activity, used for buttons and calendar. */
120 | backgroundColor: string;
121 | /** Is the color set by the user (as opposed to chosen automatically?) */
122 | manualColor = false;
123 | timeslots: Timeslot[] = [];
124 | room: string | undefined = undefined;
125 |
126 | constructor(colorScheme: ColorScheme) {
127 | this.id = nanoid(8);
128 | this.backgroundColor = fallbackColor(colorScheme);
129 | }
130 |
131 | /** Name that appears when it's on a button. */
132 | get buttonName(): string {
133 | return this.name;
134 | }
135 |
136 | /** Hours per week. */
137 | get hours(): number {
138 | return sum(this.timeslots.map((slot) => slot.hours));
139 | }
140 |
141 | /** Get all calendar events corresponding to this activity. */
142 | get events(): Event[] {
143 | return [new Event(this, this.name, this.timeslots, this.room)];
144 | }
145 |
146 | /**
147 | * Add a timeslot to this non-class activity spanning from startDate to
148 | * endDate. Dates must be within 8 AM to 9 PM. Will not add if equal to
149 | * existing timeslot. Will not add if slot spans multiple days.
150 | */
151 | addTimeslot(slot: Timeslot): void {
152 | if (
153 | this.timeslots.find((slot_) => slot_.equals(slot)) ||
154 | slot.startTime.getDate() !== slot.endTime.getDate()
155 | )
156 | return;
157 | this.timeslots.push(slot);
158 | }
159 |
160 | /** Remove a given timeslot from the non-class activity. */
161 | removeTimeslot(slot: Timeslot): void {
162 | this.timeslots = this.timeslots.filter((slot_) => !slot_.equals(slot));
163 | }
164 |
165 | /** Deflate an activity to something JSONable. */
166 | deflate(): (RawTimeslot[] | string)[] {
167 | const res = [
168 | this.timeslots.map((slot) => [
169 | slot.startSlot.slot,
170 | slot.numSlots,
171 | ]),
172 | this.name,
173 | this.backgroundColor,
174 | this.room ?? "",
175 | ];
176 | return res;
177 | }
178 |
179 | /** Inflate a non-class activity with info from the output of deflate. */
180 | inflate(parsed: (RawTimeslot[] | string)[]): void {
181 | const [timeslots, name, backgroundColor, room] = parsed;
182 | this.timeslots = (timeslots as RawTimeslot[]).map(
183 | (slot) => new Timeslot(...slot),
184 | );
185 | this.name = name as string;
186 | this.room = (room as string) || undefined;
187 | if (backgroundColor) {
188 | this.manualColor = true;
189 | this.backgroundColor = backgroundColor as string;
190 | }
191 | }
192 | }
193 |
194 | /** Shared interface for Class and NonClass. */
195 | export type Activity = Class | NonClass;
196 |
--------------------------------------------------------------------------------
/src/lib/calendarSlots.ts:
--------------------------------------------------------------------------------
1 | import type { NonClass, Timeslot } from "./activity";
2 | import type { Section, Sections, Class } from "./class";
3 |
4 | /**
5 | * Helper function for selectSlots. Implements backtracking: we try to place
6 | * freeSections while counting the number of conflicts, returning all options
7 | * with the minimum number of conflicts.
8 | *
9 | * @param freeSections - Remaining sections to schedule
10 | * @param filledSlots - Timeslots that have been scheduled
11 | * @param foundOptions - Option currently being built
12 | * @param curConflicts - Current number of conflicts of foundOptions
13 | * @param foundMinConflicts - Best number of conflicts so far
14 | * @returns Object with best options found so far and number of conflicts
15 | */
16 | function selectHelper(
17 | freeSections: Sections[],
18 | filledSlots: Timeslot[],
19 | foundOptions: Section[],
20 | curConflicts: number,
21 | foundMinConflicts: number,
22 | ): {
23 | options: Section[][];
24 | minConflicts: number;
25 | } {
26 | if (freeSections.length === 0) {
27 | return { options: [foundOptions], minConflicts: curConflicts };
28 | }
29 |
30 | let options: Section[][] = [];
31 | let minConflicts: number = foundMinConflicts;
32 |
33 | const [secs, ...remainingSections] = freeSections;
34 |
35 | for (const sec of secs.sections) {
36 | const newConflicts = sec.countConflicts(filledSlots);
37 | if (curConflicts + newConflicts > minConflicts) continue;
38 |
39 | const { options: newOptions, minConflicts: newMinConflicts } = selectHelper(
40 | remainingSections,
41 | filledSlots.concat(sec.timeslots),
42 | foundOptions.concat(sec),
43 | curConflicts + newConflicts,
44 | minConflicts,
45 | );
46 |
47 | if (newMinConflicts < minConflicts) {
48 | options = [];
49 | minConflicts = newMinConflicts;
50 | }
51 |
52 | if (newMinConflicts === minConflicts) {
53 | options.push(...newOptions);
54 | }
55 | }
56 |
57 | return { options, minConflicts };
58 | }
59 |
60 | /**
61 | * Find best options for choosing sections among classes. Returns list of list
62 | * of possible options.
63 | *
64 | * @param selectedClasses - Current classes to schedule
65 | * @returns Object with:
66 | * options - list of schedule options; each schedule option is a list of all
67 | * sections in that schedule, including locked sections (but not including
68 | * non-class activities.)
69 | * conflicts - number of conflicts in any option
70 | */
71 | export function scheduleSlots(
72 | selectedClasses: Class[],
73 | selectedNonClasses: NonClass[],
74 | ): {
75 | options: Section[][];
76 | conflicts: number;
77 | } {
78 | const lockedSections: Sections[] = [];
79 | const lockedOptions: Section[] = [];
80 | const initialSlots: Timeslot[] = [];
81 | const freeSections: Sections[] = [];
82 |
83 | for (const cls of selectedClasses) {
84 | for (const secs of cls.sections) {
85 | if (secs.locked) {
86 | const sec = secs.selected;
87 | if (sec) {
88 | lockedSections.push(secs);
89 | lockedOptions.push(sec);
90 | initialSlots.push(...sec.timeslots);
91 | } else {
92 | // locked to having no section, do nothing
93 | }
94 | } else if (secs.sections.length > 0) {
95 | freeSections.push(secs);
96 | }
97 | }
98 | }
99 |
100 | for (const activity of selectedNonClasses) {
101 | initialSlots.push(...activity.timeslots);
102 | }
103 |
104 | const result = selectHelper(freeSections, initialSlots, [], 0, Infinity);
105 |
106 | return {
107 | options: result.options,
108 | conflicts: result.minConflicts,
109 | };
110 | }
111 |
--------------------------------------------------------------------------------
/src/lib/colors.ts:
--------------------------------------------------------------------------------
1 | import type { ColorMode } from "../components/ui/color-mode";
2 | import type { Activity } from "./activity";
3 |
4 | /** The type of color schemes. */
5 | export interface ColorScheme {
6 | name: string;
7 | colorMode: ColorMode;
8 | backgroundColors: string[];
9 | }
10 |
11 | const classic: ColorScheme = {
12 | name: "Classic",
13 | colorMode: "light",
14 | backgroundColors: [
15 | "#23AF83",
16 | "#3E9ED1",
17 | "#AE7CB4",
18 | "#DE676F",
19 | "#E4793C",
20 | "#D7AD00",
21 | "#33AE60",
22 | "#F08E94",
23 | "#8FBDD9",
24 | "#A2ACB0",
25 | ],
26 | };
27 |
28 | const classicDark: ColorScheme = {
29 | name: "Classic (Dark)",
30 | colorMode: "dark",
31 | backgroundColors: [
32 | "#36C0A5",
33 | "#5EBEF1",
34 | "#CE9CD4",
35 | "#EA636B",
36 | "#FF995C",
37 | "#F7CD20",
38 | "#47CE80",
39 | "#FFAEB4",
40 | "#AFDDF9",
41 | "#C2CCD0",
42 | ],
43 | };
44 |
45 | const highContrast: ColorScheme = {
46 | name: "High Contrast",
47 | colorMode: "light",
48 | backgroundColors: [
49 | "#FF6B6B",
50 | "#FFD93D",
51 | "#4FC3F7",
52 | "#81C784",
53 | "#C580D1",
54 | "#FFADC5",
55 | "#309BF3",
56 | "#FF8A65",
57 | ],
58 | };
59 |
60 | const highContrastDark: ColorScheme = {
61 | name: "High Contrast (Dark)",
62 | colorMode: "dark",
63 | backgroundColors: [
64 | "#EB7070",
65 | "#FFE066",
66 | "#67D5E3",
67 | "#7BE27B",
68 | "#B584E6",
69 | "#FF85C0",
70 | "#66B2FF",
71 | "#FFA570",
72 | ],
73 | };
74 |
75 | /** The default color schemes. */
76 | export const COLOR_SCHEME_PRESETS: ColorScheme[] = [
77 | classic,
78 | classicDark,
79 | highContrast,
80 | highContrastDark,
81 | ];
82 |
83 | export const COLOR_SCHEME_DARK = classicDark;
84 | export const COLOR_SCHEME_LIGHT = classic;
85 | export const COLOR_SCHEME_DARK_CONTRAST = highContrastDark;
86 | export const COLOR_SCHEME_LIGHT_CONTRAST = highContrast;
87 |
88 | /** The default background color for a color scheme. */
89 | export function fallbackColor(colorScheme: ColorScheme): string {
90 | return colorScheme.colorMode === "light" ? "#4A5568" : "#CBD5E0";
91 | }
92 |
93 | /** MurmurHash3, seeded with a string. */
94 | function murmur3(str: string): () => number {
95 | let hash = 1779033703 ^ str.length;
96 | for (let i = 0; i < str.length; i++) {
97 | hash = Math.imul(hash ^ str.charCodeAt(i), 3432918353);
98 | hash = (hash << 13) | (hash >>> 19);
99 | }
100 | return () => {
101 | hash = Math.imul(hash ^ (hash >>> 16), 2246822507);
102 | hash = Math.imul(hash ^ (hash >>> 13), 3266489909);
103 | return (hash ^= hash >>> 16) >>> 0;
104 | };
105 | }
106 |
107 | export const getDefaultColorScheme = (): ColorScheme => {
108 | const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
109 | const prefersConstrast = window.matchMedia(
110 | "(prefers-constrast: more)",
111 | ).matches;
112 |
113 | if (prefersConstrast) {
114 | if (prefersDark) {
115 | return COLOR_SCHEME_DARK_CONTRAST;
116 | } else {
117 | return COLOR_SCHEME_LIGHT_CONTRAST;
118 | }
119 | } else {
120 | if (prefersDark) {
121 | return COLOR_SCHEME_DARK;
122 | } else {
123 | return COLOR_SCHEME_LIGHT;
124 | }
125 | }
126 | };
127 |
128 | /**
129 | * Assign background colors to a list of activities. Mutates each activity
130 | * in the list.
131 | */
132 | export function chooseColors(
133 | activities: Activity[],
134 | colorScheme: ColorScheme,
135 | ): void {
136 | // above this length, we give up trying to be nice:
137 | const colorLen = colorScheme.backgroundColors.length;
138 | const indices: number[] = [];
139 | for (const activity of activities) {
140 | if (activity.manualColor) continue;
141 | const hash = murmur3(activity.id);
142 | let index = hash() % colorLen;
143 | // try to pick distinct colors if possible; hash to try to make each
144 | // activity have a consistent color.
145 | while (indices.length < colorLen && indices.includes(index)) {
146 | index = hash() % colorLen;
147 | }
148 | indices.push(index);
149 | activity.backgroundColor = colorScheme.backgroundColors[index];
150 | }
151 | }
152 |
153 | /** Choose a text color for a background given by hex code color. */
154 | export function textColor(color: string): string {
155 | const r = parseInt(color.substring(1, 3), 16);
156 | const g = parseInt(color.substring(3, 5), 16);
157 | const b = parseInt(color.substring(5, 7), 16);
158 | const brightness = (r * 299 + g * 587 + b * 114) / 1000;
159 | return brightness > 128 ? "#000000" : "#ffffff";
160 | }
161 |
162 | /** Return a standard #AABBCC representation from an input color */
163 | export function canonicalizeColor(code: string): string | undefined {
164 | code = code.trim();
165 | const fiveSix = code.match(/^#?[0-9a-f]{5,6}$/gi);
166 | if (fiveSix) {
167 | return code.startsWith("#") ? code : `#${code}`;
168 | }
169 | const triplet = code.match(/^#?[0-9a-f]{3}$/gi);
170 | if (triplet) {
171 | const expanded =
172 | code.slice(-3, -2) +
173 | code.slice(-3, -2) +
174 | code.slice(-2, -1) +
175 | code.slice(-2, -1) +
176 | code.slice(-1) +
177 | code.slice(-1);
178 | return code.startsWith("#") ? expanded : `#${expanded}`;
179 | }
180 | return undefined;
181 | }
182 |
183 | /** The Google calendar background color. */
184 | // export const CALENDAR_COLOR = "#DB5E45";
185 |
--------------------------------------------------------------------------------
/src/lib/gapi.ts:
--------------------------------------------------------------------------------
1 | import type { ICalEventData } from "ical-generator";
2 | import { ICalCalendar } from "ical-generator";
3 | import { RRule, RRuleSet } from "rrule";
4 | import { tzlib_get_ical_block } from "timezones-ical-library";
5 |
6 | import type { Activity } from "./activity";
7 | import type { Term } from "./dates";
8 | import type { State } from "./state";
9 |
10 | /** Timezone string. */
11 | const TIMEZONE = "America/New_York";
12 |
13 | /** Downloads a file with the given text data */
14 | function download(filename: string, text: string) {
15 | const element = document.createElement("a");
16 | element.setAttribute(
17 | "href",
18 | "data:text/plain;charset=utf-8," + encodeURIComponent(text),
19 | );
20 | element.setAttribute("download", filename);
21 |
22 | element.style.display = "none";
23 | document.body.appendChild(element);
24 |
25 | element.click();
26 |
27 | document.body.removeChild(element);
28 | }
29 |
30 | function toICalEvents(activity: Activity, term: Term): ICalEventData[] {
31 | return activity.events.flatMap((event) =>
32 | event.slots.map((slot) => {
33 | const rawClass =
34 | "rawClass" in event.activity ? event.activity.rawClass : undefined;
35 |
36 | const start = rawClass?.quarterInfo?.start;
37 | const end = rawClass?.quarterInfo?.end;
38 | const h1 = rawClass?.half === 1;
39 | const h2 = rawClass?.half === 2;
40 |
41 | const startDate = term.startDateFor(slot.startSlot, h2, start);
42 | const startDateEnd = term.startDateFor(slot.endSlot, h2, start);
43 | const endDate = term.endDateFor(slot.startSlot, h1, end);
44 | const exDates = term.exDatesFor(slot.startSlot);
45 | const rDate = term.rDateFor(slot.startSlot);
46 |
47 | const rrule = new RRule({
48 | freq: RRule.WEEKLY,
49 | until: endDate,
50 | });
51 |
52 | const rruleSet = new RRuleSet();
53 | rruleSet.rrule(rrule);
54 |
55 | for (const exdate of exDates) {
56 | rruleSet.exdate(exdate);
57 | }
58 |
59 | if (rDate) {
60 | rruleSet.rdate(rDate);
61 | }
62 |
63 | return {
64 | summary: event.name,
65 | location: event.room,
66 | start: startDate,
67 | end: startDateEnd,
68 | timezone: TIMEZONE,
69 | repeating: rruleSet,
70 | } satisfies ICalEventData;
71 | }),
72 | );
73 | }
74 |
75 | /** Hook that returns an export calendar function. */
76 | export function useICSExport(
77 | state: State | undefined,
78 | onSuccess?: () => void,
79 | onError?: () => void,
80 | ): () => void {
81 | return () => {
82 | const cal = new ICalCalendar({
83 | name: `Hydrant: ${state?.term.niceName ?? ""}`,
84 | timezone: {
85 | name: TIMEZONE,
86 | generator: (zone) => tzlib_get_ical_block(zone)[0],
87 | },
88 | events: state?.selectedActivities.flatMap((activity) =>
89 | toICalEvents(activity, state.term),
90 | ),
91 | });
92 | console.log(cal);
93 |
94 | try {
95 | download(`${state?.term.urlName ?? ""}.ics`, cal.toString());
96 | } catch (_err) {
97 | onError?.();
98 | }
99 | onSuccess?.();
100 | };
101 | }
102 |
--------------------------------------------------------------------------------
/src/lib/hydrant.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState, createContext } from "react";
2 | import { useColorMode } from "../components/ui/color-mode";
3 |
4 | import type { TermInfo } from "../lib/dates";
5 | import type { State } from "../lib/state";
6 | import type { RawClass } from "../lib/rawClass";
7 | import type { HydrantState } from "../lib/schema";
8 | import { DEFAULT_STATE } from "../lib/schema";
9 |
10 | export interface SemesterData {
11 | classes: Record;
12 | lastUpdated: string;
13 | termInfo: TermInfo;
14 | }
15 |
16 | /** Fetch from the url, which is JSON of type T. */
17 | export const fetchNoCache = async (url: string): Promise => {
18 | const res = await fetch(url, { cache: "no-cache" });
19 | return (await res.json()) as T;
20 | };
21 |
22 | /** Hook to fetch data and initialize State object. */
23 | export function useHydrant({ globalState }: { globalState: State }): {
24 | state: State;
25 | hydrantState: HydrantState;
26 | } {
27 | const stateRef = useRef(globalState);
28 |
29 | const [hydrantState, setHydrantState] = useState(DEFAULT_STATE);
30 | const { colorMode, setColorMode, toggleColorMode } = useColorMode();
31 |
32 | const state = stateRef.current;
33 |
34 | useEffect(() => {
35 | // if colorScheme changes, change colorMode to match
36 | state.callback = (newState: HydrantState) => {
37 | setHydrantState(newState);
38 | if (
39 | newState.preferences.colorScheme &&
40 | colorMode !== newState.preferences.colorScheme.colorMode
41 | ) {
42 | // if the color scheme is not null, set the color mode to match
43 | toggleColorMode();
44 | } else if (newState.preferences.colorScheme === null) {
45 | // if the color scheme is null, set the color mode to match the system
46 | if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
47 | setColorMode("dark");
48 | } else {
49 | setColorMode("light");
50 | }
51 | }
52 | };
53 | state.updateState();
54 | // eslint-disable-next-line react-hooks/exhaustive-deps
55 | }, [colorMode, state]);
56 |
57 | return { state, hydrantState };
58 | }
59 |
60 | export const HydrantContext = createContext>({
61 | hydrantState: DEFAULT_STATE,
62 | state: {} as State,
63 | });
64 |
--------------------------------------------------------------------------------
/src/lib/rawClass.ts:
--------------------------------------------------------------------------------
1 | /** Raw timeslot format: [start slot, length of timeslot]. */
2 | export type RawTimeslot = [number, number];
3 |
4 | /** Raw section format: [[[10, 2], [70, 2]], "34-101"]. */
5 | export type RawSection = [RawTimeslot[], string];
6 |
7 | /** The raw class format produced by the scraper. */
8 | export interface RawClass {
9 | /** Class number, e.g. "6.3900" */
10 | number: string;
11 | /** Old class number, e.g. "6.036" */
12 | oldNumber: string;
13 | /** Course number, e.g. "6" */
14 | course: string;
15 | /** Subject number without course, e.g. "3900" */
16 | subject: string;
17 | /** True if some section is not scheduled yet */
18 | tba: boolean;
19 |
20 | /** Kinds of sections (among LECTURE, RECITATION, LAB, DESIGN) that exist */
21 | sectionKinds: ("lecture" | "recitation" | "lab" | "design")[];
22 | /** Lecture timeslots and rooms */
23 | lectureSections: RawSection[];
24 | /** Recitation timeslots and rooms */
25 | recitationSections: RawSection[];
26 | /** Lab timeslots and rooms */
27 | labSections: RawSection[];
28 | /** Design timeslots and rooms */
29 | designSections: RawSection[];
30 | /** Raw lecture times, e.g. T9.301-11 or TR1,F2 */
31 | lectureRawSections: string[];
32 | /** Raw recitation times, e.g. T9.301-11 or TR1,F2 */
33 | recitationRawSections: string[];
34 | /** Raw lab times, e.g. T9.301-11 or TR1,F2 */
35 | labRawSections: string[];
36 | /** Raw design times, e.g. T9.301-11 or TR1,F2 */
37 | designRawSections: string[];
38 |
39 | /** True if HASS-H */
40 | hassH: boolean;
41 | /** True if HASS-A */
42 | hassA: boolean;
43 | /** True if HASS-S */
44 | hassS: boolean;
45 | /** True if HASS-E */
46 | hassE: boolean;
47 | /** True if CI-H */
48 | cih: boolean;
49 | /** True if CI-HW */
50 | cihw: boolean;
51 | /** True if REST */
52 | rest: boolean;
53 | /** True if institute lab */
54 | lab: boolean;
55 | /** True if partial institute lab */
56 | partLab: boolean;
57 |
58 | /** Array of programs (free text) for which this class is a CI-M */
59 | cim?: string[];
60 |
61 | /** Lecture or recitation units */
62 | lectureUnits: number;
63 | /** Lab or field work units */
64 | labUnits: number;
65 | /** Outside class units */
66 | preparationUnits: number;
67 | /**
68 | * Does this class have an arranged number of units?
69 | * If true, lectureUnits, labUnits, preparationUnits are set to zero.
70 | */
71 | isVariableUnits: boolean;
72 |
73 | /** Level: "U" undergrad, "G" grad */
74 | level: "U" | "G";
75 | /**
76 | * Comma-separated list of classes with same number, e.g.
77 | * "21A.103, WGS.225"
78 | */
79 | same: string;
80 | /** Comma-separated list of classes it meets with */
81 | meets: string;
82 |
83 | /** Terms class is offered */
84 | terms: ("FA" | "JA" | "SP" | "SU")[];
85 | /** Prereqs, no specific format (but usually contains class numbers) */
86 | prereqs: string;
87 |
88 | /** Description (~paragraph that appears in catalog) */
89 | description: string;
90 | /** Name of class e.g. "Algebra I" */
91 | name: string;
92 | /** (Person) in-charge, e.g. "Alyssa Hacker" */
93 | inCharge: string;
94 |
95 | /** True if meeting virtually */
96 | virtualStatus: boolean;
97 |
98 | /** True if NOT offered next year */
99 | nonext: boolean;
100 | /** True if can be repeated for credit */
101 | repeat: boolean;
102 | /** Class website */
103 | url: string;
104 | /** True if has final */
105 | final: boolean;
106 | /** 1 or 2 if first / second half */
107 | half: number | false;
108 | /** True if limited enrollment */
109 | limited: boolean;
110 |
111 | /** Rating (out of 7.0) from evals */
112 | rating: number;
113 | /** Hours per week from evals */
114 | hours: number;
115 | /** Class size from evals */
116 | size: number;
117 |
118 | /** Record with start and end time information */
119 | quarterInfo?: Partial>;
120 | }
121 |
--------------------------------------------------------------------------------
/src/lib/schema.ts:
--------------------------------------------------------------------------------
1 | import type { Activity } from "./activity";
2 | import type { ColorScheme } from "./colors";
3 |
4 | /** A save has an ID and a name. */
5 | export interface Save {
6 | id: string;
7 | name: string;
8 | }
9 |
10 | /** Browser-specific user preferences. */
11 | export interface Preferences {
12 | colorScheme: ColorScheme | null;
13 | roundedCorners: boolean;
14 | showEventTimes: boolean;
15 | defaultScheduleId: string | null;
16 | showFeedback: boolean;
17 | }
18 |
19 | /** The default user preferences. */
20 | export const DEFAULT_PREFERENCES: Preferences = {
21 | colorScheme: null,
22 | roundedCorners: false,
23 | showEventTimes: false,
24 | defaultScheduleId: null,
25 | showFeedback: true,
26 | };
27 |
28 | /** React state. */
29 | export interface HydrantState {
30 | selectedActivities: Activity[];
31 | viewedActivity: Activity | undefined;
32 | selectedOption: number;
33 | totalOptions: number;
34 | units: number;
35 | hours: number;
36 | warnings: string[];
37 | saveId: string;
38 | saves: Save[];
39 | preferences: Preferences;
40 | }
41 |
42 | /** Default React state. */
43 | export const DEFAULT_STATE: HydrantState = {
44 | selectedActivities: [],
45 | viewedActivity: undefined,
46 | selectedOption: 0,
47 | totalOptions: 0,
48 | units: 0,
49 | hours: 0,
50 | warnings: [],
51 | saveId: "",
52 | saves: [],
53 | preferences: DEFAULT_PREFERENCES,
54 | };
55 |
--------------------------------------------------------------------------------
/src/lib/store.ts:
--------------------------------------------------------------------------------
1 | import type { Preferences, Save } from "./schema";
2 |
3 | export interface TermStore {
4 | saves: Save[];
5 | /** Array of class numbers that are starred */
6 | starredClasses: string[];
7 | [saveId: string]: unknown[];
8 | }
9 |
10 | export interface GlobalStore {
11 | preferences: Preferences;
12 | }
13 |
14 | /** Generic storage. */
15 | export class Store {
16 | /** The current term. */
17 | readonly term: string;
18 |
19 | constructor(term: string) {
20 | this.term = term;
21 | }
22 |
23 | /** Convert a key to a local storage key. */
24 | toKey(key: string, global: boolean): string {
25 | return global ? key : `${this.term}-${key}`;
26 | }
27 |
28 | /** Return the corresponding, term-specific saved value. */
29 | get(key: T): TermStore[T] | null {
30 | const result = localStorage.getItem(this.toKey(key.toString(), false));
31 | return result !== null ? (JSON.parse(result) as TermStore[T]) : null;
32 | }
33 |
34 | /** Return the corresponding global saved value. */
35 | globalGet(key: T): GlobalStore[T] | null {
36 | const result = localStorage.getItem(this.toKey(key.toString(), true));
37 | return result !== null ? (JSON.parse(result) as GlobalStore[T]) : null;
38 | }
39 |
40 | /** Set the corresponding term-specific value. */
41 | set(key: T, value: TermStore[T]): void {
42 | localStorage.setItem(
43 | this.toKey(key.toString(), false),
44 | JSON.stringify(value),
45 | );
46 | }
47 |
48 | /** Set the corresponding global saved value. */
49 | globalSet(key: T, value: GlobalStore[T]): void {
50 | localStorage.setItem(
51 | this.toKey(key.toString(), true),
52 | JSON.stringify(value),
53 | );
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/lib/utils.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "@chakra-ui/react";
2 | import { unpack, pack } from "msgpackr";
3 | import type { JSX } from "react/jsx-runtime";
4 |
5 | import type { State } from "./state";
6 |
7 | //========================================================================
8 | // Class utilities:
9 |
10 | /**
11 | * This regex matches a class number like 6.042J or 21W.THU. The groups are
12 | * courseDigits ("6", "21"), courseLetters ("", "W"), and classNumber ("042J",
13 | * "THU").
14 | */
15 | const CLASS_REGEX = new RegExp(
16 | [
17 | "^",
18 | "(?[0-9]*)",
19 | "(?[A-Z]*)",
20 | "\\.",
21 | "(?[0-9A-Z]*)",
22 | "$",
23 | ].join(""),
24 | );
25 |
26 | /** Three-way comparison for class numbers. */
27 | export function classSort(
28 | a: string | null | undefined,
29 | b: string | null | undefined,
30 | ) {
31 | if (!a && !b) return 0;
32 | if (!a) return 1;
33 | if (!b) return -1;
34 | const aGroups = CLASS_REGEX.exec(a)?.groups;
35 | const bGroups = CLASS_REGEX.exec(b)?.groups;
36 | if (!aGroups || !bGroups) return 0;
37 | const aCourseNumber = Number(aGroups.courseDigits || "Infinity");
38 | const bCourseNumber = Number(bGroups.courseDigits || "Infinity");
39 | if (aCourseNumber > bCourseNumber) return 1;
40 | if (aCourseNumber < bCourseNumber) return -1;
41 | if (aGroups.courseLetters > bGroups.courseLetters) return 1;
42 | if (aGroups.courseLetters < bGroups.courseLetters) return -1;
43 | if (aGroups.classNumber > bGroups.classNumber) return 1;
44 | if (aGroups.classNumber < bGroups.classNumber) return -1;
45 | return 0;
46 | }
47 |
48 | /** Turn a string lowercase and keep only alphanumeric characters. */
49 | export function simplifyString(s: string): string {
50 | return s.toLowerCase().replaceAll(/[^a-z0-9]/g, "");
51 | }
52 |
53 | /**
54 | * Smart class number matching. Case-insensitive. Punctuation-insensitive when
55 | * the searchString has no punctuation, but cares otherwise.
56 | */
57 | export function classNumberMatch(
58 | searchString: string,
59 | classNumber: string,
60 | exact = false,
61 | ): boolean {
62 | const process = (s: string) =>
63 | searchString.includes(".") ? s.toLowerCase() : simplifyString(s);
64 | const compare = (a: string, b: string) => (exact ? a === b : a.includes(b));
65 | return compare(process(classNumber), process(searchString));
66 | }
67 |
68 | /** Wrapper to link all classes in a given string. */
69 | export function linkClasses(state: State, str: string): JSX.Element {
70 | return (
71 | <>
72 | {str.split(/([0-9]*[A-Z]*\.[0-9A-Z]+)/).map((text, i) => {
73 | const cls = state.classes.get(text);
74 | if (!cls) return text;
75 | return (
76 | {
79 | state.setViewedActivity(cls);
80 | }}
81 | colorPalette="blue"
82 | >
83 | {text}
84 |
85 | );
86 | })}
87 | >
88 | );
89 | }
90 |
91 | //========================================================================
92 | // Other utilities:
93 |
94 | /** Takes the sum of an array. */
95 | export function sum(arr: number[]): number {
96 | return arr.reduce((acc, cur) => acc + cur, 0);
97 | }
98 |
99 | export function urlencode(obj: unknown): string {
100 | return pack(obj).toString("base64");
101 | }
102 |
103 | export function urldecode(obj: string): unknown {
104 | return unpack(Buffer.from(obj, "base64"));
105 | }
106 |
--------------------------------------------------------------------------------
/src/root.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | isRouteErrorResponse,
3 | Links,
4 | Meta,
5 | Outlet,
6 | Scripts,
7 | ScrollRestoration,
8 | } from "react-router";
9 | import type { Route } from "./+types/root";
10 |
11 | import { Provider } from "./components/ui/provider";
12 | import { Flex, Spinner, Text, Stack, Code } from "@chakra-ui/react";
13 |
14 | import "@fontsource-variable/inter/index.css";
15 |
16 | // eslint-disable-next-line react-refresh/only-export-components
17 | export const links: Route.LinksFunction = () => [
18 | { rel: "icon", type: "icon/png", href: "/hydrant.png" },
19 | ];
20 |
21 | export function HydrateFallback() {
22 | return (
23 |
24 |
25 |
26 | );
27 | }
28 |
29 | export function Layout({ children }: { children: React.ReactNode }) {
30 | return (
31 |
32 |
33 |
34 |
35 | Hydrant
36 |
37 |
38 |
43 |
44 |
45 |
46 | {children}
47 |
48 |
49 |
50 |
51 |
52 | );
53 | }
54 |
55 | export default function Root() {
56 | return ;
57 | }
58 |
59 | export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
60 | let message = "Oops!";
61 | let details = "An unexpected error occurred.";
62 | let stack: string | undefined;
63 |
64 | if (isRouteErrorResponse(error)) {
65 | message = error.status === 404 ? "404" : "Error";
66 | details =
67 | error.status === 404
68 | ? "The requested page could not be found."
69 | : error.statusText || details;
70 | } else if (import.meta.env.DEV && error && error instanceof Error) {
71 | details = error.message;
72 | stack = error.stack;
73 | }
74 |
75 | return (
76 |
77 |
78 |
79 | {message}
80 |
81 | {details}
82 | {stack && (
83 |
91 | {stack}
92 |
93 | )}
94 |
95 |
96 | );
97 | }
98 |
--------------------------------------------------------------------------------
/src/routes.ts:
--------------------------------------------------------------------------------
1 | import { type RouteConfig, index, route } from "@react-router/dev/routes";
2 |
3 | export default [
4 | index("./routes/Index.tsx"),
5 | route("overrides/:prefillId?", "./routes/Overrides.tsx"),
6 | route("export", "./routes/export.ts"),
7 | ] satisfies RouteConfig;
8 |
--------------------------------------------------------------------------------
/src/routes/Index.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useContext } from "react";
2 |
3 | import { Center, Flex, Group, Button, ButtonGroup } from "@chakra-ui/react";
4 | import { Tooltip } from "../components/ui/tooltip";
5 | import { ActivityDescription } from "../components/ActivityDescription";
6 | import { Calendar } from "../components/Calendar";
7 | import { ClassTable } from "../components/ClassTable";
8 | import { LeftFooter } from "../components/Footers";
9 | import { Header, PreferencesDialog } from "../components/Header";
10 | import { ScheduleOption } from "../components/ScheduleOption";
11 | import { ScheduleSwitcher } from "../components/ScheduleSwitcher";
12 | import { SelectedActivities } from "../components/SelectedActivities";
13 | import { TermSwitcher } from "../components/TermSwitcher";
14 | import { FeedbackBanner } from "../components/FeedbackBanner";
15 | import { MatrixLink } from "../components/MatrixLink";
16 | import { PreregLink } from "../components/PreregLink";
17 | import { LuCalendar } from "react-icons/lu";
18 |
19 | import { State } from "../lib/state";
20 | import { Term } from "../lib/dates";
21 | import { useICSExport } from "../lib/gapi";
22 | import type { SemesterData } from "../lib/hydrant";
23 | import { useHydrant, HydrantContext, fetchNoCache } from "../lib/hydrant";
24 | import { getClosestUrlName, type LatestTermInfo } from "../lib/dates";
25 |
26 | import type { Route } from "./+types/Index";
27 |
28 | // eslint-disable-next-line react-refresh/only-export-components
29 | export async function clientLoader({ request }: Route.ClientActionArgs) {
30 | const searchParams = new URL(request.url).searchParams;
31 | const urlNameOrig = searchParams.get("t");
32 |
33 | const latestTerm = await fetchNoCache("/latestTerm.json");
34 | const { urlName, shouldWarn } = getClosestUrlName(
35 | urlNameOrig,
36 | latestTerm.semester.urlName,
37 | );
38 |
39 | let termToFetch: string;
40 | if (urlName === urlNameOrig || urlNameOrig === null) {
41 | termToFetch = urlName === latestTerm.semester.urlName ? "latest" : urlName;
42 | } else {
43 | if (urlName === latestTerm.semester.urlName) {
44 | searchParams.delete("t");
45 | termToFetch = "latest";
46 | } else {
47 | searchParams.set("t", urlName);
48 | termToFetch = urlName;
49 | }
50 | if (shouldWarn) {
51 | searchParams.set("ti", urlNameOrig);
52 | }
53 | window.location.search = searchParams.toString();
54 | }
55 |
56 | const { classes, lastUpdated, termInfo } = await fetchNoCache(
57 | `/${termToFetch}.json`,
58 | );
59 | const classesMap = new Map(Object.entries(classes));
60 |
61 | return {
62 | globalState: new State(
63 | classesMap,
64 | new Term(termInfo),
65 | lastUpdated,
66 | latestTerm.semester.urlName,
67 | ),
68 | };
69 | }
70 |
71 | /** The application entry. */
72 | function HydrantApp() {
73 | const { state } = useContext(HydrantContext);
74 |
75 | const [isExporting, setIsExporting] = useState(false);
76 | // TODO: fix gcal export
77 | const onICSExport = useICSExport(
78 | state,
79 | () => {
80 | setIsExporting(false);
81 | },
82 | () => {
83 | setIsExporting(false);
84 | },
85 | );
86 |
87 | return (
88 | <>
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 | >
135 | );
136 | }
137 |
138 | // eslint-disable-next-line react-refresh/only-export-components
139 | export const meta: Route.MetaFunction = () => [
140 | { title: "Hydrant" },
141 | {
142 | name: "description",
143 | content: "Hydrant is a class planner for MIT students.",
144 | },
145 | ];
146 |
147 | /** The main application. */
148 | export default function App({ loaderData }: Route.ComponentProps) {
149 | const { globalState } = loaderData;
150 | const hydrantData = useHydrant({ globalState });
151 |
152 | return (
153 |
154 |
155 |
156 | );
157 | }
158 |
--------------------------------------------------------------------------------
/src/routes/export.ts:
--------------------------------------------------------------------------------
1 | import { redirect } from "react-router";
2 |
3 | import { fetchNoCache, type SemesterData } from "../lib/hydrant";
4 | import { getClosestUrlName, Term, type LatestTermInfo } from "../lib/dates";
5 | import { State } from "../lib/state";
6 | import { Class } from "../lib/class";
7 |
8 | import type { Route } from "./+types/export";
9 |
10 | /**
11 | * "Integration callbacks" allow other SIPB projects to integrate with Hydrant by redirecting to
12 | * https://hydrant.mit.edu/#/export with a `callback` as a query parameter.
13 | *
14 | * Currently, the only application that uses this is the Matrix class group chat picker,
15 | * but in the future, a prompt "[Application name] would like to access your Hydrant class list"
16 | * could be implemented.
17 | */
18 | const ALLOWED_INTEGRATION_CALLBACKS = [
19 | "https://matrix.mit.edu/classes/hydrantCallback",
20 | "https://uplink.mit.edu/classes/hydrantCallback",
21 | ];
22 |
23 | export async function clientLoader({ request }: Route.ClientLoaderArgs) {
24 | const searchParams = new URL(request.url).searchParams;
25 | const currentTerm = searchParams.get("t");
26 | const callback = searchParams.get("callback");
27 |
28 | const latestTerm = await fetchNoCache("/latestTerm.json");
29 | const { urlName } = getClosestUrlName(
30 | currentTerm,
31 | latestTerm.semester.urlName,
32 | );
33 |
34 | const term = urlName === latestTerm.semester.urlName ? "latest" : urlName;
35 |
36 | const { classes, lastUpdated, termInfo } = await fetchNoCache(
37 | `/${term}.json`,
38 | );
39 | const classesMap = new Map(Object.entries(classes));
40 | const hydrantObj = new State(
41 | classesMap,
42 | new Term(termInfo),
43 | lastUpdated,
44 | latestTerm.semester.urlName,
45 | );
46 |
47 | if (!callback || !ALLOWED_INTEGRATION_CALLBACKS.includes(callback)) {
48 | console.warn("callback", callback, "not in allowed callbacks list!");
49 | window.alert(`${callback ?? ""} is not allowed to read your class list!`);
50 |
51 | return redirect("/");
52 | }
53 |
54 | const encodedClasses = hydrantObj.selectedActivities
55 | .filter((activity) => activity instanceof Class)
56 | .map((cls) => `&class=${cls.number}`)
57 | .join("");
58 | const filledCallback = `${callback}?hydrant=true${encodedClasses}`;
59 | return redirect(filledCallback);
60 | }
61 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | interface ImportMetaEnv {
4 | readonly VITE_GOOGLE_CLIENT_ID: string;
5 | }
6 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": [
3 | "**/*",
4 | "**/.server/**/*",
5 | "**/.client/**/*",
6 | ".react-router/types/**/*"
7 | ],
8 | "compilerOptions": {
9 | "lib": ["DOM", "DOM.Iterable", "ES2022"],
10 | "types": ["node", "vite/client"],
11 | "target": "ES2022",
12 | "module": "ES2022",
13 | "moduleResolution": "bundler",
14 | "jsx": "react-jsx",
15 | "rootDirs": [".", "./.react-router/types"],
16 | "baseUrl": ".",
17 | "paths": {
18 | "~/*": ["./src/*"]
19 | },
20 | "esModuleInterop": true,
21 | "forceConsistentCasingInFileNames": true,
22 | "isolatedModules": true,
23 | "noEmit": true,
24 | "resolveJsonModule": true,
25 | "skipLibCheck": true,
26 | "strict": true
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { reactRouter } from "@react-router/dev/vite";
2 | import { defineConfig } from "vite";
3 | import tsconfigPaths from "vite-tsconfig-paths";
4 | import checker from "vite-plugin-checker";
5 | import { nodePolyfills } from "vite-plugin-node-polyfills";
6 |
7 | // https://vitejs.dev/config/
8 | export default defineConfig({
9 | plugins: [
10 | reactRouter(),
11 | tsconfigPaths(),
12 | nodePolyfills({ include: ["buffer"] }),
13 | checker({
14 | typescript: true,
15 | eslint: { lintCommand: "eslint **/*.{ts,tsx}", useFlatConfig: true },
16 | }),
17 | ],
18 | });
19 |
--------------------------------------------------------------------------------