diff --git a/.gitignore b/.gitignore index 362b7e16..286ff862 100644 --- a/.gitignore +++ b/.gitignore @@ -26,9 +26,9 @@ dist-ssr # artifacts scrapers/catalog.json -scrapers/fireroad-sem.json -scrapers/fireroad-presem.json +scrapers/fireroad-*.json scrapers/cim.json +scrapers/pe-*.json public/latest.json public/i26.json diff --git a/pyproject.toml b/pyproject.toml index 35d3293f..15992168 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ addopts = "--doctest-modules" [tool.pylint.main] max-line-length = 88 py-version = "3.8" +disable = "fixme" [tool.hatch.build.targets.wheel] packages = ["scrapers"] diff --git a/scrapers/__main__.py b/scrapers/__main__.py index 99223002..b1df69cb 100644 --- a/scrapers/__main__.py +++ b/scrapers/__main__.py @@ -11,6 +11,7 @@ from .cim import run as cim_run from .fireroad import run as fireroad_run from .package import run as package_run +from .pe import run as pe_run def run(): @@ -25,6 +26,8 @@ def run(): catalog_run() print("=== Update CI-M data ===") cim_run() + print("=== Update PE data ===") + pe_run() print("=== Packaging ===") package_run() diff --git a/scrapers/package.py b/scrapers/package.py index ab5fec4f..70ebb5f9 100644 --- a/scrapers/package.py +++ b/scrapers/package.py @@ -19,7 +19,8 @@ from collections.abc import Iterable from typing import Any -from .utils import get_term_info +from scrapers.pe import get_pe_quarters +from scrapers.utils import get_term_info if sys.version_info >= (3, 11): import tomllib @@ -45,32 +46,34 @@ def load_json_data(json_path: str) -> Any: return json.load(json_file) -def load_toml_data(overrides_dir: str, subpath=".") -> dict[str, Any]: +def load_toml_data(toml_path: str) -> dict[str, Any]: """ - Loads data from the provided directory that consists exclusively of TOML files + Loads data from the provided TOML file, or directory that consists exclusively of + TOML files Args: - overrides_dir (str): The directory to load from - subpath (str, optional): Load from a subdirectory. Defaults to ".". + toml_path (str): The file or directory to load from Returns: dict[str, Any]: The data contained within the directory """ - overrides_path = os.path.join(package_dir, overrides_dir) - out: dict[str, Any] = {} - - if not os.path.isdir(os.path.join(overrides_path, subpath)): - # directory doesn't exist, so we return an empty dict + toml_path = os.path.join(package_dir, toml_path) + + if os.path.isfile(toml_path): + with open(toml_path, "rb") as toml_file: + return tomllib.load(toml_file) + elif os.path.isdir(toml_path): + # If the path is a directory, we load all TOML files in it + out = {} + with os.scandir(toml_path) as entries: + for entry in entries: + if entry.is_file() and entry.name.endswith(".toml"): + with open(entry.path, "rb") as toml_file: + out.update(tomllib.load(toml_file)) return out - - # If the path is a directory, we load all TOML files in it - toml_dir = os.path.join(overrides_path, subpath) - for fname in os.listdir(toml_dir): - if fname.endswith(".toml"): - with open(os.path.join(toml_dir, fname), "rb") as toml_file: - out.update(tomllib.load(toml_file)) - - return out + else: + # Neither a file nor a directory exists as this path, so we return an empty dict + return {} def merge_data( @@ -118,6 +121,7 @@ def get_include(overrides: dict[str, dict[str, Any]]) -> set[str]: return classes +# pylint: disable=too-many-locals def run() -> None: """ The main entry point. @@ -135,7 +139,7 @@ def run() -> None: for sem in sem_types: fireroad_sem = load_json_data(f"fireroad-{sem}.json") - overrides_sem = load_toml_data("overrides.toml.d", sem) + overrides_sem = load_toml_data(os.path.join("overrides.toml.d", sem)) # The key needs to be in BOTH fireroad and catalog to make it: # If it's not in Fireroad, it's not offered in this semester (fall, etc.). @@ -150,11 +154,17 @@ def run() -> None: term_info = get_term_info(sem) url_name = term_info["urlName"] - obj: dict[str, dict[str, Any] | str | dict[Any, dict[str, Any]]] = { - "termInfo": term_info, - "lastUpdated": now, - "classes": courses, - } + pe_data = {} + for quarter in get_pe_quarters(url_name): + pe_file = f"pe-q{quarter}.json" + pe_overrides_file = os.path.join("pe", f"pe-q{quarter}-overrides.toml") + if os.path.isfile(os.path.join(package_dir, pe_file)): + quarter_data = load_json_data(pe_file) + quarter_overrides = load_toml_data(pe_overrides_file) + pe_data[quarter] = merge_data( + datasets=[quarter_data, quarter_overrides], + keys_to_keep=set(quarter_data), + ) with open( os.path.join( @@ -163,7 +173,16 @@ def run() -> None: mode="w", encoding="utf-8", ) as file: - json.dump(obj, file, separators=(",", ":")) + json.dump( + { + "termInfo": term_info, + "lastUpdated": now, + "classes": courses, + "pe": pe_data, + }, + file, + separators=(",", ":"), + ) print(f"{url_name}: got {len(courses)} courses") diff --git a/scrapers/pe.py b/scrapers/pe.py new file mode 100644 index 00000000..0bc2812b --- /dev/null +++ b/scrapers/pe.py @@ -0,0 +1,497 @@ +""" +Adds information from PE&W subjects, as given by DAPER. +""" + +from __future__ import annotations + +import csv +from functools import lru_cache +import json +import os +import time as time_c +from datetime import date, time +from typing import Literal, TypedDict +from urllib.request import Request, urlopen + +from bs4 import BeautifulSoup + +from scrapers.fireroad import parse_section +from scrapers.utils import Term + +PE_CATALOG = ( + "https://physicaleducationandwellness.mit.edu/options-for-points/course-catalog/" +) + +# ask DAPER how they represent summer... +QUARTERS: dict[int, tuple[Term, Literal[1, 2] | None]] = { + 1: (Term.FA, 1), + 2: (Term.FA, 2), + 3: (Term.SP, 1), + 4: (Term.SP, 2), + 5: (Term.JA, None), +} + +WELLNESS_PREFIXES = ["PE.05", "PE.4"] + +PIRATE_CLASSES = [ + "Archery", + "Fencing", + "Pistol", + "Air Pistol", + "Rifle", # TODO ask if air rifle is also eligible + "Sailing", +] + +# I don't really like how this looks tbh, +# but typing as a class doesn't allow for spaces in vars +PEWFile = TypedDict( + "PEWFile", + { + "Term": str, + "Section": str, + "Title": str, + "Capacity": str, + "Day": str, + "Time": str, + "Location": str, + "Start Date": str, + "End Date": str, + "Prerequisites": str, + "Equipment": str, + "GIR Points": str, + "Swim GIR": str, + "Fee Amount": str, + }, +) +""" +Data from CSV file representing PE&W subjects, as given by DAPER +""" + + +class PEWSchema(TypedDict): + """ + Information expected by the frontend (see rawPEClass.ts) + """ + + number: str + name: str + sectionNumbers: list[str] + sections: list[tuple[list[tuple[int, int]], str]] + rawSections: list[str] + classSize: int + startDate: str + endDate: str + points: int + wellness: bool + pirate: bool + swimGIR: bool + prereqs: str + equipment: str + fee: str + description: str + quarter: int + + +def parse_bool(value: str) -> bool: + """ + Parses bool from "Y" or "N" (or throws an error) + + Args: + value (str): The string to parse + + Raises: + ValueError: If the value is not "Y" or "N" + + Returns: + bool: The parsed boolean value + """ + if value.upper() == "Y": + return True + if value.upper() == "N": + return False + + raise ValueError(f"Invalid boolean value: {value}") + + +def augment_location(location: str) -> str: + """ + Adds the building number to a location. Returns its input if there are multiple + locations detected, in which case an override would be more appropriate, or if no + suitable building could be identified. + + Args: + location (str): The raw location to parse + + Returns: + str: The location, with a building number possibly prepended + + >>> augment_location("Du Pont T Club Lounge") + 'W35 - Du Pont T Club Lounge' + + >>> augment_location("Harvard") + 'Harvard' + + >>> augment_location("Du Pont T Club Lounge and 26-100") + 'Du Pont T Club Lounge and 26-100' + """ + buildings = { + "Du Pont": "W35", + "Zesiger": "W35", + "Rockwell": "W35", + "Johnson": "W35", + } + + if " and " in location: + return location + + for loc, building in buildings.items(): + if location.startswith(loc): + return f"{building} - {location}" + + return location + + +def read_pew_file(filepath: str) -> list[PEWFile]: + """ + Parses PE&W data from file according to a specific format from a CSV + + Args: + filepath (str): The path to the CSV file + + Returns: + list[PEWFile]: A list of PEWFile dictionaries representing the parsed data + """ + pew_data: list[PEWFile] + cols = getattr(PEWFile, "__annotations__").keys() + with open(filepath, mode="r", newline="", encoding="utf-8") as csvfile: + reader = csv.DictReader(csvfile) + pew_data = [] + for row in reader: + assert all( + col in row for col in cols + ), f"Missing columns in PEW file: {filepath}" + pew_data.append({col: row[col] for col in cols}) # type: ignore + return pew_data + + +def get_year_quarter(term_str: str) -> tuple[int, int]: + """ + Extracts the quarter from a term string. + + Args: + term_str (str): The term string in the format "YYYYQ" + + Returns: + tuple[int, int]: The year and quarter extracted from the term string + + Raises: + ValueError: If the term string format is invalid + + >>> get_year_quarter("2026Q2") + (2026, 2) + + >>> get_year_quarter("2026Q3") + (2026, 3) + """ + + # Validate term string format + if len(term_str) != 6 or term_str[4] != "Q" or int(term_str[5]) not in QUARTERS: + raise ValueError(f"Invalid term string format: {term_str}") + + year = int(term_str[:4]) + quarter = int(term_str[5]) + return year, quarter + + +def term_to_semester_year(term_str: str) -> tuple[int, Term, Literal[1, 2] | None]: + """ + Converts a term string to a Term enum and semester half + along with the academic year. + + Args: + term_str (str): The term string in the format "YYYYQ" + where Q is the quarter number + + Returns: + tuple[int, Term, Literal[1, 2] | None]: A tuple containing + the year, Term enum, and semester half + + >>> term_to_semester_year("2026Q2") + (2025, , 2) + + >>> term_to_semester_year("2026Q5") + (2026, , None) + + >>> term_to_semester_year("2026Q3") + (2026, , 1) + """ + + year, quarter = get_year_quarter(term_str) + term, semester = QUARTERS[quarter] + + if term == Term.FA: + year -= 1 # Fall term belongs to the previous academic year + + return (year, term, semester) + + +def split_section_code(section_code: str) -> tuple[str, str]: + """ + Splits a section code into its subject and section number components. + + Args: + section_code (str): The section code in the format "PE.0201-1" + + Returns: + tuple[str, str]: A tuple containing the subject and section number components + + Raises: + ValueError: If the section code format is invalid + + >>> split_section_code("PE.0201-1") + ('PE.0201', '1') + + >>> split_section_code("PE.0613-4") + ('PE.0613', '4') + """ + if "-" not in section_code: + raise ValueError(f"Invalid section code format: {section_code}") + subject, number = section_code.rsplit("-", 1) + return subject, number + + +def parse_date(date_str: str) -> date: + """ + Parses a date string in the format "MM/DD/YYYY" to a datetime.date object. + + Args: + date_str (str): The date string in the format "MM/DD/YYYY" + + Returns: + datetime.date: The parsed date object + + Raises: + ValueError: If the date string format is invalid + + >>> parse_date("9/1/2023") + datetime.date(2023, 9, 1) + + >>> parse_date("12/31/2024") + datetime.date(2024, 12, 31) + """ + month, day, year = map(int, date_str.split("/")) + return date(year, month, day) + + +def parse_times_to_raw_section(start_time: str, days: str, location: str) -> str: + """ + Parses times from CVS to format from Fireroad, for compatibility. + + Args: + start_time (str): Start time of the class + days (str): Days the class meets + location (str): Location of the class + + Returns: + str: Formatted raw section string + """ + start_c = time_c.strptime(start_time, "%I:%M %p") + start = time(start_c.tm_hour, start_c.tm_min) + end = time( + start.hour + 1, start.minute + ) # default to 1 hour, can be changed in overrides + + start_raw_time = ( + f"{12 - ((- start.hour) % 12)}" f"{'.30' if start.minute > 29 else ''}" + ) + end_raw_time = ( + f"{12 - ((- end.hour) % 12)}" + f"{'.30' if end.minute > 29 else ''}" + f"{' PM' if end.hour >= 17 else ''}" + ) + evening = "1" if end.hour >= 17 else "0" + + return f"{location}/{days}/{evening}/{start_raw_time}-{end_raw_time}" + + +def parse_data(row: PEWFile, quarter: int) -> PEWSchema: + """ + Parses a single PEWFile row into PEWSchema format. + + Args: + row (PEWFile): The PEWFile row to parse + quarter (int): The quarter the data is for + + Returns: + PEWSchema: The parsed PEWSchema object + """ + number, section_num = split_section_code(row["Section"]) + raw_section = parse_times_to_raw_section( + row["Time"], + row["Day"], + augment_location(row["Location"]), + ) + section = parse_section(raw_section) + + return { + "number": number, + "name": row["Title"], + "sectionNumbers": [section_num], + "rawSections": [raw_section], + "sections": [section], + "classSize": int(row["Capacity"]), + "startDate": parse_date(row["Start Date"]).isoformat(), + "endDate": parse_date(row["End Date"]).isoformat(), + "points": int(row["GIR Points"]), + "wellness": any(number.startswith(prefix) for prefix in WELLNESS_PREFIXES), + "pirate": any(row["Title"].startswith(prefix) for prefix in PIRATE_CLASSES), + "swimGIR": parse_bool(row["Swim GIR"]), + "prereqs": row["Prerequisites"] or "None", + "equipment": row["Equipment"], + "fee": row["Fee Amount"], + "description": get_pe_catalog_descriptions().get(number, ""), + "quarter": quarter, + } + + +def pe_rows_to_schema(pe_rows: list[PEWFile]) -> dict[int, dict[str, PEWSchema]]: + """ + Converts PEWFile dictionaries to a standardized schema dictionary. + + Args: + pe_rows (list[PEWFile]): The list of PEWFile dictionaries to convert + + Returns: + dict: A dictionary representing the standardized schema, + keyed by quarter and subject number + """ + + results: dict[int, dict[str, PEWSchema]] = {} + + for pe_row in pe_rows: + _, quarter = get_year_quarter(pe_row["Term"]) + + term_results = results.get(quarter) + if term_results is None: + term_results = {} + results[quarter] = term_results + + data = parse_data(pe_row, quarter) + current_results = term_results.get(data["number"]) + + if current_results: + # ensure all data in current_results (except for section info) are the same + assert current_results["name"] == data["name"] + assert current_results["classSize"] == data["classSize"] + assert current_results["points"] == data["points"] + assert current_results["swimGIR"] == data["swimGIR"] + assert current_results["prereqs"] == data["prereqs"] or ( + current_results["prereqs"] == "None" and not data["prereqs"] + ) + assert current_results["equipment"] == data["equipment"] + assert current_results["fee"] == data["fee"] + + current_results["sectionNumbers"].append(data["sectionNumbers"][0]) + current_results["rawSections"].append(data["rawSections"][0]) + current_results["sections"].append(data["sections"][0]) + + term_results[data["number"]] = current_results + else: + term_results[data["number"]] = data + + results[quarter] = term_results + + return results + + +@lru_cache(maxsize=None) +def get_pe_catalog_descriptions() -> dict[str, str]: + """ + Scrapes PE&W course descriptions from the DAPER PE&W catalog. + + Returns: + dict[str, str]: A dictionary mapping course numbers to their descriptions. + """ + + request = Request(PE_CATALOG) + request.add_header("User-Agent", "Mozilla/5.0 (compatible; HydrantBot/1.0)") + with urlopen(request, timeout=15) as response: + soup = BeautifulSoup(response.read().decode("utf-8"), features="lxml") + + accordions = soup.select("div.accordion") + descriptions: dict[str, str] = {} + + for accordion in accordions: + header = accordion.find(class_="header") + assert header + header_small = header.find("small") + assert header_small + header_text = header_small.get_text(strip=True) + + description = accordion.find(class_="accoridon-content") + assert description + description_p = description.find("p") + assert description_p + description_text = description_p.get_text(strip=True) + + descriptions[header_text] = description_text + + return descriptions + + +def get_pe_quarters(url_name: str) -> list[str]: + """ + Gets the list of parsed PE files for a given urlName. + + Args: + url_name (str): The urlName to get PE files for + + Returns: + list[str]: The list of PE quarters for the term + + >>> get_pe_quarters("f26") + [1, 2] + + >>> get_pe_quarters("i26") + [5] + """ + + assert url_name[0] in ("f", "i", "s", "m"), "Invalid urlName format" + + return { + "f": [1, 2], # Fall + "s": [3, 4], # Spring + "i": [5], # IAP + "m": [], # Summer + }[url_name[0]] + + +def run(): + """ + Main entry point for PE data + """ + + # get list of csv files in the pe data directory + pe_folder = os.path.join(os.path.dirname(__file__), "pe") + pe_files = os.listdir(pe_folder) + + pe_files_data = [] + for pe_file in pe_files: + if pe_file.endswith(".csv"): + # process the data as needed + pe_files_data.extend(read_pew_file(os.path.join(pe_folder, pe_file))) + + pe_data = pe_rows_to_schema(pe_files_data) + + for quarter, quarter_data in pe_data.items(): + print(f"Processed PE data for quarter {quarter}: {len(quarter_data)} subjects") + fname = os.path.join(os.path.dirname(__file__), f"pe-q{quarter}.json") + + with open(fname, "w", encoding="utf-8") as pe_output_file: + json.dump(quarter_data, pe_output_file) + + return pe_data + + +if __name__ == "__main__": + run() diff --git a/scrapers/pe/pe-q3-overrides.toml b/scrapers/pe/pe-q3-overrides.toml new file mode 100644 index 00000000..1beec9ec --- /dev/null +++ b/scrapers/pe/pe-q3-overrides.toml @@ -0,0 +1,9 @@ +["PE.0201"] +# SCUBA Diving +sections = [[[[59, 7]], "57 and 66-160"], [[[127, 7]], "57 and 66-160"]] +rawSections = ["57 and 66-160/T/1/6.30-10 PM", "57 and 66-160/R/1/6.30-10 PM"] + +["PE.0922"] +# Parkour, Beginner +sections = [[[[150, 4]], "W35 - Zesiger MAC Court"]] +rawSections = ["W35 - Zesiger MAC Court/F/0/1-3"] diff --git a/scrapers/pe/pe-q3.csv b/scrapers/pe/pe-q3.csv new file mode 100644 index 00000000..8525b26b --- /dev/null +++ b/scrapers/pe/pe-q3.csv @@ -0,0 +1,69 @@ +Term,Section,Title,Capacity,Day,Time,Location,Start Date,End Date,Prerequisites,Equipment,GIR Points,Swim GIR,Fee Amount +2026Q3,PE.0201-1,SCUBA Diving,18,T,6:45 PM,Alumni Wang Pool and 66-160,2/24/2026,4/14/2026,"Q3 2026.Tue Dates: 2/24, 3/3, 3/10, 3/17**, 3/31**, 4/7**, 4/14** (** Ends in Q4). Thu Dates: 2/19, 2/26, 3/5, 3/12, 3/19**, 4/2**, 4/9**(** Ends in Q4). Meet at Alumni Wang pool/Classroom 66-160 after. All participants must complete PE&W Swim/Boat test by 2/4 to register along with passing SCUBA pre-test on day 1, able to lift 40 lbs and in good health. Must complete all documentation forms from PE&W to confirm registration by 2/9 @ 5p. Attendance is required on the first and last day.","Bathing suit or shorts and shirt. Equipment provided by United Divers for pool sessions. A mask, booties, fins and a snorkel must be purchased for open water dives. ",4,N,$365.00 +2026Q3,PE.0201-2,SCUBA Diving,18,R,6:45 PM,Alumni Wang Pool and 66-160,2/19/2026,4/9/2026,"Q3 2026.Tue Dates: 2/24, 3/3, 3/10, 3/17**, 3/31**, 4/7**, 4/14** (** Ends in Q4). Thu Dates: 2/19, 2/26, 3/5, 3/12, 3/19**, 4/2**, 4/9**(** Ends in Q4). Meet at Alumni Wang pool/Classroom 66-160 after. All participants must complete PE&W Swim/Boat test by 2/4 to register along with passing SCUBA pre-test on day 1, able to lift 40 lbs and in good health. Must complete all documentation forms from PE&W to confirm registration by 2/9 @ 5p. Attendance is required on the first and last day.","Bathing suit or shorts and shirt. Equipment provided by United Divers for pool sessions. A mask, booties, fins and a snorkel must be purchased for open water dives. ",4,N,$365.00 +2026Q3,PE.0202-1,"Swimming, Beginner",10,MW,11:00 AM,Zesiger Teaching Pool,2/11/2026,3/18/2026,None,Swim attire needed. Students should bring a filled water bottle and towel. It is recommended to leave your clothing/bags in a locked locker or bring with you on the pool deck.,2,Y,$20.00 +2026Q3,PE.0202-2,"Swimming, Beginner",10,MW,1:00 PM,Zesiger Teaching Pool,2/11/2026,3/18/2026,None,Swim attire needed. Students should bring a filled water bottle and towel. It is recommended to leave your clothing/bags in a locked locker or bring with you on the pool deck.,2,Y,$20.00 +2026Q3,PE.0202-3,"Swimming, Beginner",10,MW,2:00 PM,Zesiger Teaching Pool,2/11/2026,3/18/2026,None,Swim attire needed. Students should bring a filled water bottle and towel. It is recommended to leave your clothing/bags in a locked locker or bring with you on the pool deck.,2,Y,$20.00 +2026Q3,PE.0202-4,"Swimming, Beginner",10,TR,11:00 AM,Zesiger Teaching Pool,2/10/2026,3/19/2026,None,Swim attire needed. Students should bring a filled water bottle and towel. It is recommended to leave your clothing/bags in a locked locker or bring with you on the pool deck.,2,Y,$20.00 +2026Q3,PE.0202-5,"Swimming, Beginner",10,TR,1:00 PM,Zesiger Teaching Pool,2/10/2026,3/19/2026,None,Swim attire needed. Students should bring a filled water bottle and towel. It is recommended to leave your clothing/bags in a locked locker or bring with you on the pool deck.,2,Y,$20.00 +2026Q3,PE.0202-6,"Swimming, Beginner",10,TR,2:00 PM,Zesiger Teaching Pool,2/10/2026,3/19/2026,None,Swim attire needed. Students should bring a filled water bottle and towel. It is recommended to leave your clothing/bags in a locked locker or bring with you on the pool deck.,2,Y,$20.00 +2026Q3,PE.0300-1,Ballroom,20,TR,7:00 PM,Du Pont T Club Lounge,2/10/2026,3/19/2026,None,"Workout clothes, footwear and water bottle.",2,N,$0.00 +2026Q3,PE.0403-1,Group Exercise - Cardio Kickboxing,20,MW,6:00 PM,Du Pont T Club Lounge,2/11/2026,3/18/2026,None,"Workout clothes, footwear and water bottle",2,N,$0.00 +2026Q3,PE.0405-1,Group Exercise - Pilates,20,TR,3:00 PM,Du Pont T Club Lounge,2/10/2026,3/19/2026,None,"Workout clothes, footwear and water bottle.",2,N,$0.00 +2026Q3,PE.0411-1,Group Exercise- Yoga,20,MW,8:00 AM,Du Pont T Club Lounge,2/11/2026,3/18/2026,None,Workout clothes and water bottle.,2,N,$0.00 +2026Q3,PE.0411-2,Group Exercise- Yoga,20,MW,5:00 PM,Du Pont T Club Lounge,2/11/2026,3/18/2026,None,Workout clothes and water bottle.,2,N,$0.00 +2026Q3,PE.0411-3,Group Exercise- Yoga,20,TR,5:00 PM,Du Pont T Club Lounge,2/10/2026,3/19/2026,None,Workout clothes and water bottle.,2,N,$0.00 +2026Q3,PE.0414-1,Weight Training,16,MW,11:00 AM,Du Pont Varsity Weight Room,2/11/2026,3/18/2026,Students must attend first 4 classes.,"Workout clothes, footwear and water bottle.",2,N,$0.00 +2026Q3,PE.0414-2,Weight Training,16,MW,1:00 PM,Du Pont Varsity Weight Room,2/11/2026,3/18/2026,Students must attend first 4 classes.,"Workout clothes, footwear and water bottle.",2,N,$0.00 +2026Q3,PE.0414-3,Weight Training,16,MW,2:00 PM,Du Pont Varsity Weight Room,2/11/2026,3/18/2026,Students must attend first 4 classes.,"Workout clothes, footwear and water bottle.",2,N,$0.00 +2026Q3,PE.0414-4,Weight Training,16,TR,11:00 AM,Du Pont Varsity Weight Room,2/10/2026,3/19/2026,Students must attend first 4 classes.,"Workout clothes, footwear and water bottle.",2,N,$0.00 +2026Q3,PE.0414-5,Weight Training,16,TR,2:00 PM,Du Pont Varsity Weight Room,2/10/2026,3/19/2026,Students must attend first 4 classes.,"Workout clothes, footwear and water bottle.",2,N,$0.00 +2026Q3,PE.0432-1,Group Exercise- Barre Fitness,15,TR,2:00 PM,Du Pont T Club Lounge,2/10/2026,3/19/2026,None,Workout clothes and water bottle.,2,N,$0.00 +2026Q3,PE.0435-1,Group Exercise- Functional Fitness,20,MW,3:00 PM,Du Pont T Club Lounge,2/11/2026,3/18/2026,None,"Workout clothes, footwear and water bottle.",2,N,$0.00 +2026Q3,PE.0444-1,Group Exercise- HIIT,20,TR,6:00 PM,Du Pont T Club Lounge,2/10/2026,3/19/2026,None,"Workout clothes, footwear and water bottle.",2,N,$0.00 +2026Q3,PE.0517-1,Fitness (Yoga)/CPR/First Aid,12,MW,4:00 PM,Du Pont Multi-Purpose Room,2/11/2026,3/18/2026,"Q3 2026: Students must complete the remote asynchronous CPR content and in-person CPR and FA exam sessions to become CPR/First Aid certified and students must be able to kneel and use 2 arms to give compressions. In person class starts Tue, Feb. 17 (switch day)",Workout clothes and water bottle. Lab fee covers pocket mask and CPR and first aid certification cards,2,N,$60.00 +2026Q3,PE.0518-1,Fitness (Yoga)/Meditation,16,TR,6:00 PM,Du Pont Multi-Purpose Room,2/10/2026,3/19/2026,None,Workout clothes and a filled water bottle.,2,N,$0.00 +2026Q3,PE.0529-1,Fitness(Yoga)/Meditation (remote synchronous),15,MW,7:00 PM,Remote Synchronous,2/11/2026,3/18/2026,"This remote synchronous course requires internet access, computer (or tablet, mobile device) with a camera, microphone, and working speaker, MIT Zoom account, roughly 6 foot x 6 foot physical area clear of any objects with a standard 7 - 8 foot ceiling and non-slip floor to do physical activity, comfortable with using 'camera on' function during Zoom sessions. ","Workout clothing and filled water bottle, mat or towel. See other prerequisites.",2,N,$0.00 +2026Q3,PE.0544-1,Fitness(Strength Circuit)/Nutrition,16,MW,5:00 PM,Du Pont Multi-Purpose Room,2/11/2026,3/18/2026,None,"Sneakers/footwear, comfortable workout clothing and water bottle. +",2,N,$0.00 +2026Q3,PE.0545-1,Fitness(Strength Circuit)/Resiliency,16,MW,3:00 PM,Du Pont Multi-Purpose Room,2/11/2026,3/18/2026,None,Workout clothes and filled water bottle.,2,N,$0.00 +2026Q3,PE.0546-1,Fitness(Strength Circuit)/Stress Management,16,MW,6:00 PM,Du Pont Multi-Purpose Room,2/11/2026,3/18/2026,None,"Sneakers/footwear, comfortable workout clothing and water bottle.",2,N,$0.00 +2026Q3,PE.0600-1,Archery,14,TR,10:00 AM,Rockwell Cage North,2/10/2026,3/19/2026,Students must attend first 4 classes.,"Work out clothes, footwear, and a filled water bottle. ",2,N,$15.00 +2026Q3,PE.0600-2,Archery,14,TR,11:00 AM,Rockwell Cage North,2/10/2026,3/19/2026,Students must attend first 4 classes.,"Work out clothes, footwear, and a filled water bottle. ",2,N,$15.00 +2026Q3,PE.0600-3,Archery,14,TR,1:00 PM,Rockwell Cage North,2/10/2026,3/19/2026,Students must attend first 4 classes.,"Work out clothes, footwear, and a filled water bottle. ",2,N,$15.00 +2026Q3,PE.0600-4,Archery,14,TR,2:00 PM,Rockwell Cage North,2/10/2026,3/19/2026,Students must attend first 4 classes.,"Work out clothes, footwear, and a filled water bottle. ",2,N,$15.00 +2026Q3,PE.0600-5,Archery,14,MW,1:00 PM,Rockwell Cage North,2/11/2026,3/18/2026,Students must attend first 4 classes.,"Work out clothes, footwear, and a filled water bottle. ",2,N,$15.00 +2026Q3,PE.0600-6,Archery,14,MW,2:00 PM,Rockwell Cage North,2/11/2026,3/18/2026,Students must attend first 4 classes.,"Work out clothes, footwear, and a filled water bottle. ",2,N,$15.00 +2026Q3,PE.0601-1,Badminton,16,TR,1:00 PM,Rockwell Cage South,2/10/2026,3/19/2026,None,"Work out clothes, footwear(court shoes preferred), and a filled water bottle. ",2,N,$10.00 +2026Q3,PE.0601-2,Badminton,16,TR,2:00 PM,Rockwell Cage South,2/10/2026,3/19/2026,None,"Work out clothes, footwear(court shoes preferred), and a filled water bottle. ",2,N,$10.00 +2026Q3,PE.0603-1,"Fencing, Sabre",16,TR,3:00 PM,Du Pont Fencing Room,2/10/2026,3/19/2026,Students must attend first 4 classes.,Workout clothes,2,N,$15.00 +2026Q3,PE.0608-1,Pistol,14,TR,1:00 PM,Du Pont Pistol Range,2/10/2026,3/19/2026,"Students must attend first 4 classes, though attendance at all classes is strongly recommended. +","Baseball style hats (old fashioned type with the brim to the front, not the rear), are mandatory. All other equipment will be provided. If students own their own protective shooting glasses and/or hearing protection they may use same after approval by the instructor.",2,N,$35.00 +2026Q3,PE.0608-2,Pistol,14,TR,2:00 PM,Du Pont Pistol Range,2/10/2026,3/19/2026,"Students must attend first 4 classes, though attendance at all classes is strongly recommended. +","Baseball style hats (old fashioned type with the brim to the front, not the rear), are mandatory. All other equipment will be provided. If students own their own protective shooting glasses and/or hearing protection they may use same after approval by the instructor.",2,N,$35.00 +2026Q3,PE.0609-1,"Pistol, Intermediate",14,TR,11:00 AM,Du Pont Pistol Range,2/10/2026,3/19/2026,"Student must have successfully completed the MIT PEandW Beginner Pistol Course. Note: Student must attend first 4 classes, though attendance at all classes is strongly recommended.","Baseball style hats (old fashioned type with the brim to the front, not the rear), are mandatory. All other equipment will be provided. If students own their own protective shooting glasses and/or hearing protection they may use same after approval by the instructor. + +",2,N,$35.00 +2026Q3,PE.0612-1,"Skate, Beginner",20,MW,1:00 PM,Johnson Ice Rink 1,2/11/2026,3/18/2026,None,Skates and a helmet- provided at the rink. Warm clothes and gloves/mittens.,2,N,$20.00 +2026Q3,PE.0612-2,"Skate, Beginner",20,TR,11:00 AM,Johnson Ice Rink 1,2/10/2026,3/19/2026,None,Skates and a helmet- provided at the rink. Warm clothes and gloves/mittens.,2,N,$20.00 +2026Q3,PE.0612-3,"Skate, Beginner",20,TR,1:00 PM,Johnson Ice Rink 1,2/10/2026,3/19/2026,None,Skates and a helmet- provided at the rink. Warm clothes and gloves/mittens.,2,N,$20.00 +2026Q3,PE.0612-4,"Skate, Beginner",20,TR,2:00 PM,Johnson Ice Rink 1,2/10/2026,3/19/2026,None,Skates and a helmet- provided at the rink. Warm clothes and gloves/mittens.,2,N,$20.00 +2026Q3,PE.0613-1,"Skate, Intermediate",15,MW,1:00 PM,Johnson Ice Rink 2,2/11/2026,3/18/2026,"Prior skate experience. Students must be able to skate forward, backward and stop.",Skates and helmet- provided at rink. Warm clothes and gloves/mittens.,2,N,$20.00 +2026Q3,PE.0613-2,"Skate, Intermediate",15,TR,11:00 AM,Johnson Ice Rink 2,2/10/2026,3/19/2026,"Prior skate experience. Students must be able to skate forward, backward and stop.",Skates and helmet- provided at rink. Warm clothes and gloves/mittens.,2,N,$20.00 +2026Q3,PE.0613-3,"Skate, Intermediate",15,TR,1:00 PM,Johnson Ice Rink 2,2/10/2026,3/19/2026,"Prior skate experience. Students must be able to skate forward, backward and stop.",Skates and helmet- provided at rink. Warm clothes and gloves/mittens.,2,N,$20.00 +2026Q3,PE.0613-4,"Skate, Intermediate",15,TR,2:00 PM,Johnson Ice Rink 2,2/10/2026,3/19/2026,"Prior skate experience. Students must be able to skate forward, backward and stop.",Skates and helmet- provided at rink. Warm clothes and gloves/mittens.,2,N,$20.00 +2026Q3,PE.0616-1,Squash,12,MW,11:00 AM,Zesiger Squash Courts,2/11/2026,3/18/2026,None,"Work out clothes, footwear(non-marking or gum soled), and a filled water bottle. ",2,N,$10.00 +2026Q3,PE.0616-2,Squash,12,TR,1:00 PM,Zesiger Squash Courts,2/10/2026,3/19/2026,None,"Work out clothes, footwear(non-marking or gum soled), and a filled water bottle. ",2,N,$10.00 +2026Q3,PE.0616-3,Squash,12,TR,2:00 PM,Zesiger Squash Courts,2/10/2026,3/19/2026,None,"Work out clothes, footwear(non-marking or gum soled), and a filled water bottle. ",2,N,$10.00 +2026Q3,PE.0617-1,"Squash, Intermediate",12,TR,11:00 AM,Zesiger Squash Courts,2/10/2026,3/19/2026,"Completion of Beginner Squash class or had experience in high school or club. Please email instructor at bbubna@mit.edu if you are not sure regarding your ability or if you have any questions +","Work out clothes, footwear(non-marking or gum soled), and a filled water bottle. Racquet, ball and eye protection are provided.",2,N,$10.00 +2026Q3,PE.0626-1,Rifle,14,MW,11:00 AM,Du Pont Pistol Range,2/11/2026,3/18/2026,"Students must attend first 4 classes, though attendance at all classes is strongly recommended.","Baseball style hats (old fashioned type with the brim to the front, not the rear), are mandatory. All other equipment will be provided. If students own their own protective shooting glasses and/or hearing protection they may use same after approval by the instructor.",2,N,$35.00 +2026Q3,PE.0626-2,Rifle,14,MW,1:00 PM,Du Pont Pistol Range,2/11/2026,3/18/2026,"Students must attend first 4 classes, though attendance at all classes is strongly recommended.","Baseball style hats (old fashioned type with the brim to the front, not the rear), are mandatory. All other equipment will be provided. If students own their own protective shooting glasses and/or hearing protection they may use same after approval by the instructor.",2,N,$35.00 +2026Q3,PE.0626-3,Rifle,14,MW,2:00 PM,Du Pont Pistol Range,2/11/2026,3/18/2026,"Students must attend first 4 classes, though attendance at all classes is strongly recommended.","Baseball style hats (old fashioned type with the brim to the front, not the rear), are mandatory. All other equipment will be provided. If students own their own protective shooting glasses and/or hearing protection they may use same after approval by the instructor.",2,N,$35.00 +2026Q3,PE.0636-1,Self-Defense for Women,20,MW,1:00 PM,Du Pont Wrestling Room,2/11/2026,3/18/2026,This is an all female course.,None,2,N,$0.00 +2026Q3,PE.0646-1,Pickleball,16,MW,11:00 AM,Rockwell Cage South,2/11/2026,3/18/2026,None,"Work out clothes, footwear, and a filled water bottle. ",2,N,$10.00 +2026Q3,PE.0657-1,Spec Tennis,16,MW,2:00 PM,Rockwell Cage South,2/11/2026,3/18/2026,None,Comfortable clothing and footwear.,2,N,$10.00 +2026Q3,PE.0701-1,Ice Hockey,20,MW,2:00 PM,Johnson Ice Rink,2/11/2026,3/18/2026,This course requires a command of forward and backward skating as well as a strong consistent stop that can be learned in beginner skate or equivalent (email instructor using physicaleducationandwellness@mit.edu address if you have questions related to your ability).,"Ice hockey skates, helmet, shin guards, gloves and hockey stick provided at rink. ",2,N,$20.00 +2026Q3,PE.0703-1,"Soccer, Beginner",15,MW,11:00 AM,Zesiger MAC Court,2/11/2026,3/18/2026,This course will be held indoors.,Court shoes recommended for indoor play. Workout clothes. ,2,N,$0.00 +2026Q3,PE.0800-1,Aikido,20,TR,1:00 PM,Du Pont Wrestling Room,2/10/2026,3/19/2026,None,Workout clothes,2,N,$0.00 +2026Q3,PE.0922-1,"Parkour, Beginner",16,F,1:15 PM,Zesiger MAC Court,2/13/2026,3/20/2026,"2/13, 2/20, 2/27, 3/6, 3/13, 3/20**(** Ends in Q4). Time: 1:15p-2:45p Registration is pending until all forms sent from PE&W office have been completed by Mon, 2/9 by 5p. Forms will be sent from the PE&W office using the student's MIT email by the close of online registration. Check SPAM folders if emails are being forwarded from an MIT email account.",Workout clothes. Court shoes recommended.,2,N,$75.00 diff --git a/src/components/ActivityButtons.tsx b/src/components/ActivityButtons.tsx index 62f91160..2d3c508e 100644 --- a/src/components/ActivityButtons.tsx +++ b/src/components/ActivityButtons.tsx @@ -24,10 +24,17 @@ import { Checkbox } from "./ui/checkbox"; import { Field } from "./ui/field"; import { Radio, RadioGroup } from "./ui/radio"; -import type { Activity, NonClass } from "../lib/activity"; -import { Timeslot } from "../lib/activity"; -import type { Class, SectionLockOption, Sections } from "../lib/class"; -import { LockOption } from "../lib/class"; +import { + Timeslot, + LockOption, + type Activity, + type CustomActivity, + type Sections, + type SectionLockOption, +} from "../lib/activity"; +import type { Class } from "../lib/class"; +import type { PEClass } from "../lib/pe"; +import { PESection } from "../lib/pe"; import { Slot, TIMESLOT_STRINGS, WEEKDAY_STRINGS } from "../lib/dates"; import { HydrantContext } from "../lib/hydrant"; @@ -107,10 +114,10 @@ function OverrideLocations(props: { secs: Sections }) { } /** Div containing section manual selection interface. */ -function ClassManualSections(props: { cls: Class }) { +function ClassManualSections(props: { cls: Class | PEClass }) { const { cls } = props; const { state } = useContext(HydrantContext); - const genSelected = (cls: Class) => + const genSelected = (cls: Class | PEClass) => cls.sections.map((sections) => sections.locked ? sections.selected @@ -129,8 +136,12 @@ function ClassManualSections(props: { cls: Class }) { return humanReadable ? "Auto (default)" : LockOption.Auto; } else if (sec === LockOption.None) { return LockOption.None; + } else if (!humanReadable) { + return sec.rawTime; + } else if (sec instanceof PESection) { + return `${sec.sectionNumber}: ${sec.parsedTime}`; } else { - return humanReadable ? sec.parsedTime : sec.rawTime; + return sec.parsedTime; } }; @@ -139,7 +150,7 @@ function ClassManualSections(props: { cls: Class }) { {cls.sections.map((secs, sectionIndex) => { const options = [LockOption.Auto, LockOption.None, ...secs.sections]; return ( - + void }) { } /** Buttons in class description to add/remove class, and lock sections. */ -export function ClassButtons(props: { cls: Class }) { +export function ClassButtons(props: { cls: Class | PEClass }) { const { cls } = props; const { state } = useContext(HydrantContext); const [showManual, setShowManual] = useState(false); @@ -298,8 +309,8 @@ export function ClassButtons(props: { cls: Class }) { ); } -/** Form to add a timeslot to a non-class. */ -function NonClassAddTime(props: { activity: NonClass }) { +/** Form to add a timeslot to a custom activity. */ +function CustomActivityAddTime(props: { activity: CustomActivity }) { const { activity } = props; const { state } = useContext(HydrantContext); const [days, setDays] = useState( @@ -395,9 +406,9 @@ function NonClassAddTime(props: { activity: NonClass }) { } /** - * Buttons in non-class description to rename it, or add/edit/remove timeslots. + * Buttons in custom activity description to rename it, or add/edit/remove timeslots. */ -export function NonClassButtons(props: { activity: NonClass }) { +export function CustomActivityButtons(props: { activity: CustomActivity }) { const { activity } = props; const { state } = useContext(HydrantContext); @@ -453,7 +464,7 @@ export function NonClassButtons(props: { activity: NonClass }) { }; const onConfirmRename = () => { - state.renameNonClass(activity, name); + state.renameCustomActivity(activity, name); setIsRenaming(false); }; const onCancelRename = () => { @@ -461,7 +472,7 @@ export function NonClassButtons(props: { activity: NonClass }) { }; const onConfirmRelocating = () => { - state.relocateNonClass(activity, room); + state.relocateCustomActivity(activity, room); setIsRelocating(false); }; const onCancelRelocating = () => { @@ -534,7 +545,7 @@ export function NonClassButtons(props: { activity: NonClass }) { Click and drag on an empty time in the calendar to add the times for your activity. Or add one manually: - + ); } diff --git a/src/components/ActivityDescription.tsx b/src/components/ActivityDescription.tsx index 5dc2e9ab..c5ee9427 100644 --- a/src/components/ActivityDescription.tsx +++ b/src/components/ActivityDescription.tsx @@ -13,24 +13,26 @@ import { import { useColorModeValue } from "./ui/color-mode"; import { Tooltip } from "./ui/tooltip"; -import type { NonClass } from "../lib/activity"; +import { CustomActivity } from "../lib/activity"; import type { Flags } from "../lib/class"; import { Class, DARK_IMAGES, getFlagImg } from "../lib/class"; import { linkClasses } from "../lib/utils"; import { HydrantContext } from "../lib/hydrant"; -import { ClassButtons, NonClassButtons } from "./ActivityButtons"; +import { ClassButtons, CustomActivityButtons } from "./ActivityButtons"; import { LuExternalLink } from "react-icons/lu"; +import { type PEFlags } from "../lib/pe"; +import { PEClass, getPEFlagEmoji } from "../lib/pe"; /** A small image indicating a flag, like Spring or CI-H. */ -function TypeSpan(props: { flag?: keyof Flags; title: string }) { +function ClassTypeSpan(props: { flag: keyof Flags; title: string }) { const { flag, title } = props; const filter = useColorModeValue( "", - flag && DARK_IMAGES.includes(flag) ? "invert()" : "", + DARK_IMAGES.includes(flag) ? "invert()" : "", ); - return flag ? ( + return ( {title} - ) : ( - <>{title} ); } -/** Header for class description; contains flags and related classes. */ +/** An emoji with tooltip indicating a flag, like Wellness Wizard. */ +function PEClassTypeSpan(props: { flag: keyof PEFlags; title: string }) { + const { flag, title } = props; + + return ( + + {getPEFlagEmoji(flag)} + + ); +} + +/** Header for class description; contains flags and units. */ function ClassTypes(props: { cls: Class }) { const { cls } = props; const { state } = useContext(HydrantContext); const { flags, totalUnits, units } = cls; /** - * Wrap a group of flags in TypeSpans. + * Wrap a group of flags in ClassTypeSpans. * * @param arr - Arrays with [flag name, alt text]. */ @@ -60,7 +71,7 @@ function ClassTypes(props: { cls: Class }) { arr .filter(([flag, _]) => flags[flag]) .map(([flag, title]) => ( - + )); const currentYear = parseInt(state.term.fullRealYear); @@ -107,13 +118,11 @@ function ClassTypes(props: { cls: Class }) { ]); const halfType = - flags.half === 1 ? ( - - ) : flags.half === 2 ? ( - - ) : ( - "" - ); + flags.half === 1 + ? "; first half of term" + : flags.half === 2 + ? "; second half of term" + : ""; const unitsDescription = cls.isVariableUnits ? "Units arranged" @@ -135,6 +144,39 @@ function ClassTypes(props: { cls: Class }) { ); } +/** Header for PE class description; contains class size, points, and flags. */ +function PEClassTypes(props: { cls: PEClass }) { + const { cls } = props; + const { flags } = cls; + const { classSize, points } = cls.rawClass; + + /** + * Wrap a group of flags in PEClassTypeSpans. + * + * @param arr - Arrays with [flag name, tooltip text]. + */ + const makeFlags = (arr: [keyof PEFlags, string][]) => + arr + .filter(([flag, _]) => flags[flag]) + .map(([flag, title]) => ( + + )); + + const types = makeFlags([ + ["wellness", "Wellness Wizard eligible"], + ["pirate", "Pirate Certificate eligible"], + ["swim", "Satisfies swim GIR"], + ]); + + return ( + + Class size: {classSize} + Awards {points} PE points + {types} + + ); +} + /** List of related classes, appears after flags and before description. */ function ClassRelated(props: { cls: Class }) { const { cls } = props; @@ -245,14 +287,14 @@ function ClassDescription(props: { cls: Class }) { ); } -/** Full non-class activity description, from title to timeslots. */ -function NonClassDescription(props: { activity: NonClass }) { +/** Full custom activity description, from title to timeslots. */ +function CustomActivityDescription(props: { activity: CustomActivity }) { const { activity } = props; const { state } = useContext(HydrantContext); return ( - + {activity.timeslots.map((t) => ( @@ -272,17 +314,109 @@ function NonClassDescription(props: { activity: NonClass }) { ); } -/** Activity description, whether class or non-class. */ +/** Full PE&W class description, from title to URLs at the end. */ +function PEClassDescription(props: { cls: PEClass }) { + const { cls } = props; + const { fee, startDate, endDate } = cls; + const { number, name, prereqs, equipment, description } = cls.rawClass; + + const fmt = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + timeZone: "UTC", + }); + + const start = fmt.format(startDate); + const end = fmt.format(endDate); + + const urls = [ + { + label: "PE&W registration", + url: "https://physicaleducationandwellness.mit.edu/registration-information/registration/", + }, + { + label: "Waitlist info", + url: "https://physicaleducationandwellness.mit.edu/registration-information/registration/waitlist/", + }, + { + label: "Student history", + url: "https://physicaleducationandwellness.mit.edu/my-gir/student-course-history/", + }, + { + label: "PE&W FAQs", + url: "https://physicaleducationandwellness.mit.edu/faqs/", + }, + ]; + + return ( + + + {number}: {name} + + + + {fee ? ${fee.toFixed(2)} enrollment fee : null} + + Begins {start}, ends {end}. + + Schedule subject to change once online registration opens. + + + + + {description} + + + Prereq:{" "} + + {prereqs} + + + + Equipment:{" "} + + {equipment} + + + + + {urls.map(({ label, url }) => ( + + {label} + + ))} + + + ); +} + +/** Activity description, whether class, PE class, or custom activity. */ export function ActivityDescription() { const { hydrantState } = useContext(HydrantContext); const { viewedActivity: activity } = hydrantState; if (!activity) { return null; } + if (activity instanceof Class) { + return ; + } + if (activity instanceof PEClass) { + return ; + } + if (activity instanceof CustomActivity) { + return ; + } - return activity instanceof Class ? ( - - ) : ( - - ); + activity satisfies never; } diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx index aea593c3..57033020 100644 --- a/src/components/Banner.tsx +++ b/src/components/Banner.tsx @@ -11,6 +11,7 @@ import { } from "@chakra-ui/react"; import { HydrantContext } from "../lib/hydrant"; +import { BANNER_MESSAGE } from "~/lib/schema"; export const Banner = () => { const { state } = useContext(HydrantContext); @@ -37,7 +38,7 @@ export const Banner = () => { > - IAP and Spring 2026 classes are now available! ❄️🌹 + {BANNER_MESSAGE} { + setIsExporting(false); + }, + () => { + setIsExporting(false); + }, + ); + + return ( + + + + ); +} + +/** A link to SIPB Matrix's class group chat importer UI */ +export function MatrixLink() { + const { + state: { selectedActivities }, + } = useContext(HydrantContext); + + // reference: https://github.com/gabrc52/class_group_chats/tree/main/src/routes/import + const matrixLink = `https://matrix.mit.edu/classes/import?via=Hydrant${selectedActivities + .filter((activity) => activity instanceof Class) + .map((cls) => `&class=${cls.number}`) + .join("")}`; + + return ( + + + + ); +} + +/** A link to SIPB Matrix's class group chat importer UI */ +export function PreregLink() { + const { + state: { selectedActivities }, + } = useContext(HydrantContext); + + // reference: https://github.com/gabrc52/class_group_chats/tree/main/src/routes/import + const preregLink = `https://student.mit.edu/cgi-bin/sfprwtrm.sh?${selectedActivities + .filter((activity) => activity instanceof Class) + .map((cls) => cls.number) + .join(",")}`; + + return ( + + + + ); +} + +export function SIPBLogo() { + return ( + + + by SIPB + SIPB Logo + + + ); +} diff --git a/src/components/Calendar.tsx b/src/components/Calendar.tsx index 370c66cf..50e25e60 100644 --- a/src/components/Calendar.tsx +++ b/src/components/Calendar.tsx @@ -9,7 +9,7 @@ import timeGridPlugin from "@fullcalendar/timegrid"; import interactionPlugin from "@fullcalendar/interaction"; import type { Activity } from "../lib/activity"; -import { NonClass, Timeslot } from "../lib/activity"; +import { CustomActivity, Timeslot } from "../lib/activity"; import { Slot } from "../lib/dates"; import { Class } from "../lib/class"; import { HydrantContext } from "../lib/hydrant"; @@ -99,9 +99,9 @@ export function Calendar() { } slotMaxTime="22:00:00" weekends={false} - selectable={viewedActivity instanceof NonClass} + selectable={viewedActivity instanceof CustomActivity} select={(e) => { - if (viewedActivity instanceof NonClass) { + if (viewedActivity instanceof CustomActivity) { state.addTimeslot( viewedActivity, Timeslot.fromStartEnd( diff --git a/src/components/ClassTable.tsx b/src/components/ClassTable.tsx index b9cf60bf..3f4f3f93 100644 --- a/src/components/ClassTable.tsx +++ b/src/components/ClassTable.tsx @@ -563,7 +563,7 @@ export function ClassTable() { sortable: false, flex: 1, valueFormatter: (params) => - `${params.data?.class.new ? "✨ " : ""}${params.value ?? ""}`, + (params.data?.class.new ? "✨ " : "") + (params.value ?? ""), }, ]; }, [state]); diff --git a/src/components/ClassTypes.tsx b/src/components/ClassTypes.tsx new file mode 100644 index 00000000..22bf74e5 --- /dev/null +++ b/src/components/ClassTypes.tsx @@ -0,0 +1,63 @@ +import { Tabs } from "@chakra-ui/react"; +import { useContext, useState } from "react"; + +import { ClassTable } from "./ClassTable"; +import { PEClassTable } from "./PEClassTable"; +import { ClassType } from "~/lib/schema"; +import { LuGraduationCap, LuDumbbell } from "react-icons/lu"; +import type { IconType } from "react-icons/lib"; +import { HydrantContext } from "~/lib/hydrant"; + +function classTypeComponents(termKeys: ClassType[]) { + const obj = {} as Record; + + if (termKeys.includes(ClassType.ACADEMIC)) { + obj[ClassType.ACADEMIC] = [LuGraduationCap, ClassTable]; + } + + if (termKeys.includes(ClassType.PEW)) { + obj[ClassType.PEW] = [LuDumbbell, PEClassTable]; + } + + return obj; +} + +export const ClassTypesSwitcher = () => { + const { state } = useContext(HydrantContext); + const [currentTab, setCurrentTab] = useState(ClassType.ACADEMIC); + + const tabs = classTypeComponents([ + ...(state.classes.size > 0 ? [ClassType.ACADEMIC] : []), + ...(state.peClasses.size > 0 ? [ClassType.PEW] : []), + ]); + + if (Object.keys(tabs).length > 1) + return ( + { + setCurrentTab(e.value as ClassType); + }} + > + + {Object.entries(tabs).map(([key, [Icon]]) => ( + + + {key} + + ))} + + + {Object.entries(tabs).map(([key, [_, Component]]) => ( + + + + ))} + + ); + + return Object.entries(tabs).map(([_k, [_i, Component]]) => ); +}; diff --git a/src/components/Footers.tsx b/src/components/Footers.tsx index 0c5e9e39..b25cfbf8 100644 --- a/src/components/Footers.tsx +++ b/src/components/Footers.tsx @@ -69,11 +69,7 @@ function AboutDialog() { We'd like to thank CJ Quines '23 for creating Hydrant and Edward Fan '19 for creating{" "} - + , the basis for Hydrant. We'd also like to thank the{" "} - + {" "} - team for collaborating with us. + team and{" "} + + + DAPER + + {" "} + for collaborating with us. diff --git a/src/components/Header.tsx b/src/components/Header.tsx index a06f0bd0..c82e8be8 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -24,7 +24,7 @@ import { HydrantContext } from "../lib/hydrant"; import logo from "../assets/logo.svg"; import logoDark from "../assets/logo-dark.svg"; import hydraAnt from "../assets/hydraAnt.png"; -import { SIPBLogo } from "./SIPBLogo"; +import { SIPBLogo } from "./ButtonsLinks"; export function PreferencesDialog() { const { state, hydrantState } = useContext(HydrantContext); diff --git a/src/components/MatrixLink.tsx b/src/components/MatrixLink.tsx deleted file mode 100644 index 53be5853..00000000 --- a/src/components/MatrixLink.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { useContext } from "react"; - -import { Class } from "../lib/class"; -import { HydrantContext } from "../lib/hydrant"; - -import { LuMessagesSquare } from "react-icons/lu"; -import { Tooltip } from "./ui/tooltip"; -import { Link } from "react-router"; -import { Button } from "@chakra-ui/react"; - -/** A link to SIPB Matrix's class group chat importer UI */ -export function MatrixLink() { - const { - state: { selectedActivities }, - } = useContext(HydrantContext); - - // reference: https://github.com/gabrc52/class_group_chats/tree/main/src/routes/import - const matrixLink = `https://matrix.mit.edu/classes/import?via=Hydrant${selectedActivities - .filter((activity) => activity instanceof Class) - .map((cls) => `&class=${cls.number}`) - .join("")}`; - - return ( - - - - ); -} diff --git a/src/components/PEClassTable.tsx b/src/components/PEClassTable.tsx new file mode 100644 index 00000000..44b023bd --- /dev/null +++ b/src/components/PEClassTable.tsx @@ -0,0 +1,519 @@ +// TODO factor out common pieces between ClassTable and PEClassTable +import { + useContext, + useEffect, + useMemo, + useRef, + useState, + type Dispatch, + type ReactNode, + type SetStateAction, +} from "react"; + +import { AgGridReact } from "ag-grid-react"; +import { + ModuleRegistry, + ClientSideRowModelModule, + ValidationModule, + ExternalFilterModule, + RenderApiModule, + CellStyleModule, + themeQuartz, + type IRowNode, + type ColDef, + type Module, +} from "ag-grid-community"; + +import { + Box, + Flex, + Input, + Button, + ButtonGroup, + InputGroup, + CloseButton, +} from "@chakra-ui/react"; +import { LuPlus, LuMinus, LuSearch, LuStar } from "react-icons/lu"; + +import { type PEFlags, type PEClass, getPEFlagEmoji } from "../lib/pe"; +import { classNumberMatch, classSort, simplifyString } from "../lib/utils"; +import "./ClassTable.scss"; +import { HydrantContext } from "../lib/hydrant"; +import type { State } from "../lib/state"; + +const hydrantTheme = themeQuartz.withParams({ + accentColor: "var(--chakra-colors-fg)", + backgroundColor: "var(--chakra-colors-bg)", + borderColor: "var(--chakra-colors-border)", + browserColorScheme: "inherit", + fontFamily: "inherit", + foregroundColor: "var(--chakra-colors-fg)", + headerBackgroundColor: "var(--chakra-colors-bg-subtle)", + rowHoverColor: "var(--chakra-colors-color-palette-subtle)", + wrapperBorderRadius: "var(--chakra-radii-md)", +}); + +const GRID_MODULES: Module[] = [ + ClientSideRowModelModule, + ExternalFilterModule, + CellStyleModule, + RenderApiModule, + ...(import.meta.env.DEV ? [ValidationModule] : []), +]; + +ModuleRegistry.registerModules(GRID_MODULES); + +enum ColorEnum { + Muted = "ag-cell-muted-text", + Success = "ag-cell-success-text", + Warning = "ag-cell-warning-text", + Error = "ag-cell-error-text", + Normal = "ag-cell-normal-text", +} + +const getFeeColor = (fee: number) => { + if (isNaN(fee)) return ColorEnum.Muted; + if (fee == 0) return ColorEnum.Success; + if (fee <= 20) return ColorEnum.Warning; + return ColorEnum.Error; +}; + +/** A single row in the class table. */ +interface ClassTableRow { + number: string; + classSize: number; + fee: number; + name: string; + class: PEClass; +} + +type ClassFilter = (cls?: PEClass) => boolean; +/** Type of filter on class list; null if no filter. */ +type SetClassFilter = Dispatch>; + +/** + * Textbox for typing in the name or number of the class to search. Maintains + * the {@link ClassFilter} that searches for a class name/number. + */ +function ClassInput(props: { + /** All rows in the class table. */ + rowData: ClassTableRow[]; + /** Callback for updating the class filter. */ + setInputFilter: SetClassFilter; +}) { + const { rowData, setInputFilter } = props; + const { state } = useContext(HydrantContext); + + // State for textbox input. + const [classInput, setClassInput] = useState(""); + const inputRef = useRef(null); + + // Search results for classes. + const searchResults = useRef< + { + number: string; + name: string; + class: PEClass; + }[] + >(undefined); + + const processedRows = useMemo( + () => + rowData.map((data) => { + return { + number: data.number, + name: simplifyString(data.name), + class: data.class, + }; + }), + [rowData], + ); + + const onClassInputChange = (input: string) => { + if (input) { + const simplifyInput = simplifyString(input); + searchResults.current = processedRows.filter( + (row) => + classNumberMatch(input, row.number) || + row.name.includes(simplifyInput), + ); + const index = new Set(searchResults.current.map((row) => row.number)); + setInputFilter( + () => (cls?: PEClass) => index.has(cls?.rawClass.number ?? ""), + ); + } else { + setInputFilter(null); + } + setClassInput(input); + }; + + const onEnter = () => { + const { number, class: cls } = searchResults.current?.[0] ?? {}; + if ( + searchResults.current?.length === 1 || + (number && classNumberMatch(number, classInput, true)) + ) { + // first check if the first result matches + state.toggleActivity(cls); + onClassInputChange(""); + } else if (state.peClasses.has(classInput)) { + // else check if this number exists exactly + const cls = state.peClasses.get(classInput); + state.toggleActivity(cls); + } + }; + + const clearButton = classInput ? ( + { + onClassInputChange(""); + inputRef.current?.focus(); + }} + me="-2" + /> + ) : undefined; + + return ( + +
{ + e.preventDefault(); + onEnter(); + }} + style={{ width: "100%", maxWidth: "30em" }} + > + } + endElement={clearButton} + width="fill-available" + > + { + onClassInputChange(e.target.value); + }} + /> + +
+
+ ); +} + +const filtersNonFlags = { + fits: (state, cls) => state.fitsSchedule(cls), + starred: (state, cls) => state.isPEClassStarred(cls), +} satisfies Record boolean>; + +type Filter = keyof PEFlags | keyof typeof filtersNonFlags; +type FilterGroup = [Filter, string, ReactNode?][]; + +/** List of top filter IDs and their displayed names. */ +const CLASS_FLAGS_1: FilterGroup = [ + ["starred", "Starred", ], + ["nofee", "No fee"], + ["nopreq", "No prereq"], + ["fits", "Fits schedule"], +]; + +/** List of hidden filter IDs, their displayed names, and image path, if any. */ +const CLASS_FLAGS_2: FilterGroup = [ + ["wellness", "🔮 Wellness Wizard"], + ["pirate", "🏴‍☠️ Pirate Certificate"], + ["swim", "🌊 Swim GIR"], +]; + +const CLASS_FLAGS = [...CLASS_FLAGS_1, ...CLASS_FLAGS_2]; + +/** Div containing all the flags like "HASS". Maintains the flag filter. */ +function ClassFlags(props: { + /** Callback for updating the class filter. */ + setFlagsFilter: SetClassFilter; + /** Callback for updating the grid filter manually. */ + updateFilter: () => void; +}) { + const { setFlagsFilter, updateFilter } = props; + const { state } = useContext(HydrantContext); + + // Map from flag to whether it's on. + const [flags, setFlags] = useState>(() => { + const result = new Map(); + for (const flag of CLASS_FLAGS) { + result.set(flag, false); + } + return result; + }); + + // Show hidden flags? + const [allFlags, setAllFlags] = useState(false); + + // this callback needs to get called when the set of classes change, because + // the filter has to change as well + useEffect(() => { + state.fitsScheduleCallback = () => { + if (flags.get("fits")) { + updateFilter(); + } + }; + }, [state, flags, updateFilter]); + + const onChange = (flag: Filter, value: boolean) => { + const newFlags = new Map(flags); + newFlags.set(flag, value); + setFlags(newFlags); + + // careful! we have to wrap it with a () => because otherwise React will + // think it's an updater function instead of the actual function. + setFlagsFilter(() => (cls?: PEClass) => { + if (!cls) return false; + let result = true; + newFlags.forEach((value, flag) => { + if ( + value && + flag in filtersNonFlags && + !filtersNonFlags[flag as keyof typeof filtersNonFlags](state, cls) + ) { + result = false; + } else if ( + value && + !(flag in filtersNonFlags) && + !cls.flags[flag as keyof typeof cls.flags] + ) { + result = false; + } + }); + return result; + }); + }; + + const renderGroup = (group: FilterGroup) => { + return ( + + {group.map(([flag, label, image]) => { + const checked = flags.get(flag); + + // hide starred button if no classes starred + if ( + flag === "starred" && + state.getStarredPEClasses().length === 0 && + !checked + ) { + return null; + } + + return image ? ( + // image is a react element, like an icon + + ) : ( + + ); + })} + + ); + }; + + return ( + + + {renderGroup(CLASS_FLAGS_1)} + + + {allFlags && <>{renderGroup(CLASS_FLAGS_2)}} + + ); +} + +const StarButton = ({ + cls, + onStarToggle, +}: { + cls: PEClass; + onStarToggle?: () => void; +}) => { + const { state } = useContext(HydrantContext); + const isStarred = state.isPEClassStarred(cls); + + return ( + + ); +}; + +/** The table of all classes, along with searching and filtering with flags. */ +export function PEClassTable() { + const { state } = useContext(HydrantContext); + const { peClasses } = state; + + const gridRef = useRef>(null); + + // Setup table columns + const columnDefs: ColDef[] = useMemo(() => { + const initialSort = "asc" as const; + const sortingOrder: ("asc" | "desc")[] = ["asc", "desc"]; + const sortProps = { sortable: true, unSortIcon: true, sortingOrder }; + return [ + { + headerName: "", + field: "number", + maxWidth: 49, + cellRenderer: (params: { data: ClassTableRow }) => ( + { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + gridRef.current?.api?.refreshCells({ + force: true, + columns: ["number"], + }); + }} + /> + ), + sortable: false, + cellStyle: { padding: 0 }, + }, + { + field: "number", + headerName: "Class", + comparator: classSort, + initialSort, + maxWidth: 93, + ...sortProps, + }, + { + field: "classSize", + headerName: "Size", + maxWidth: 85, + ...sortProps, + }, + { + field: "fee", + maxWidth: 87, + cellClass: (params) => getFeeColor(params.value as number), + valueFormatter: (params) => "$" + (params.value as number).toFixed(2), + ...sortProps, + }, + { + field: "name", + sortable: false, + flex: 1, + valueFormatter: (params) => + Object.entries(params.data?.class.flags ?? ({} as PEFlags)) + .filter(([_, val]) => val) + .map(([flag]) => getPEFlagEmoji(flag as keyof PEFlags)) + .concat([params.value?.toString() ?? ""]) + .join(" "), + }, + ]; + }, [state]); + + const defaultColDef: ColDef = useMemo(() => { + return { + resizable: false, + }; + }, []); + + // Setup rows + const rowData: ClassTableRow[] = useMemo( + () => + Array.from(peClasses.values(), (cls) => ({ + number: cls.rawClass.number, + classSize: cls.rawClass.classSize, + fee: cls.fee, + name: cls.rawClass.name, + class: cls, + // TODO figure out if we get PE instructor names + })), + [peClasses], + ); + + const [inputFilter, setInputFilter] = useState(null); + const [flagsFilter, setFlagsFilter] = useState(null); + + const doesExternalFilterPass = useMemo(() => { + return (node: IRowNode) => { + if (inputFilter && !inputFilter(node.data?.class)) return false; + if (flagsFilter && !flagsFilter(node.data?.class)) return false; + return true; + }; + }, [inputFilter, flagsFilter]); + + // Need to notify grid every time we update the filter + useEffect(() => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + gridRef.current?.api?.onFilterChanged(); + }, [doesExternalFilterPass]); + + return ( + + + gridRef.current?.api?.onFilterChanged()} + /> + + + theme={hydrantTheme} + ref={gridRef} + defaultColDef={defaultColDef} + columnDefs={columnDefs} + rowData={rowData} + suppressMovableColumns={true} + enableCellTextSelection={true} + isExternalFilterPresent={() => true} + doesExternalFilterPass={doesExternalFilterPass} + onRowClicked={(e) => { + state.setViewedActivity(e.data?.class); + }} + onRowDoubleClicked={(e) => { + state.toggleActivity(e.data?.class); + }} + // these have to be set here, not in css: + headerHeight={40} + rowHeight={40} + /> + + + ); +} diff --git a/src/components/PreregLink.tsx b/src/components/PreregLink.tsx deleted file mode 100644 index 3579da50..00000000 --- a/src/components/PreregLink.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { Class } from "../lib/class"; -import { LuClipboardCopy } from "react-icons/lu"; - -import { Tooltip } from "./ui/tooltip"; -import { useContext } from "react"; -import { HydrantContext } from "../lib/hydrant"; -import { Link } from "react-router"; -import { Button } from "@chakra-ui/react"; - -/** A link to SIPB Matrix's class group chat importer UI */ -export function PreregLink() { - const { - state: { selectedActivities }, - } = useContext(HydrantContext); - - // reference: https://github.com/gabrc52/class_group_chats/tree/main/src/routes/import - const preregLink = `https://student.mit.edu/cgi-bin/sfprwtrm.sh?${selectedActivities - .filter((activity) => activity instanceof Class) - .map((cls) => cls.number) - .join(",")}`; - - return ( - - - - ); -} diff --git a/src/components/SIPBLogo.tsx b/src/components/SIPBLogo.tsx deleted file mode 100644 index 85a45705..00000000 --- a/src/components/SIPBLogo.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Image, Link as ChakraLink } from "@chakra-ui/react"; -import { Link } from "react-router"; -import sipbLogo from "../assets/simple-fuzzball.png"; - -export function SIPBLogo() { - return ( - - - by SIPB - SIPB Logo - - - ); -} diff --git a/src/lib/activity.ts b/src/lib/activity.ts index 69daca38..fc131aa9 100644 --- a/src/lib/activity.ts +++ b/src/lib/activity.ts @@ -1,12 +1,13 @@ import type { EventInput } from "@fullcalendar/core"; import { nanoid } from "nanoid"; -import type { Class } from "./class"; import type { ColorScheme } from "./colors"; import { fallbackColor, textColor } from "./colors"; import { Slot } from "./dates"; -import type { RawTimeslot } from "./rawClass"; +import type { RawSection, RawTimeslot } from "./raw"; import { sum } from "./utils"; +import type { PEClass } from "./pe"; +import type { Class } from "./class"; /** A period of time, spanning several Slots. */ export class Timeslot { @@ -65,13 +66,37 @@ export class Timeslot { } } +/** + * Shared interface for all types of activities, + * including classes, PE classes, and custom activities. + */ +export interface BaseActivity { + id: string; + backgroundColor: string; + manualColor: boolean; + hours: number; + buttonName: string; + warnings?: { + suffix: string; + messages: string[]; + }; + events: Event[]; + start?: [number, number]; + end?: [number, number]; + deflate(): unknown; + inflate?(parsed: unknown): void; + half?: number; +} + +export type Activity = Class | PEClass | CustomActivity; + /** * A group of events to be rendered in a calendar, all of the same name, room, * and color. */ export class Event { /** The parent activity owning the event. */ - activity: Activity; + activity: BaseActivity; /** The name of the event. */ name: string; /** All slots of the event. */ @@ -82,7 +107,7 @@ export class Event { half?: number; constructor( - activity: Activity, + activity: BaseActivity, name: string, slots: Timeslot[], room?: string, @@ -111,8 +136,8 @@ export class Event { } } -/** A non-class activity. */ -export class NonClass { +/** A custom activity, created by the user. */ +export class CustomActivity implements BaseActivity { /** ID unique over all Activities. */ readonly id: string; name = "New Activity"; @@ -144,7 +169,7 @@ export class NonClass { } /** - * Add a timeslot to this non-class activity spanning from startDate to + * Add a timeslot to this custom activity spanning from startDate to * endDate. Dates must be within 8 AM to 9 PM. Will not add if equal to * existing timeslot. Will not add if slot spans multiple days. */ @@ -157,7 +182,7 @@ export class NonClass { this.timeslots.push(slot); } - /** Remove a given timeslot from the non-class activity. */ + /** Remove a given timeslot from the custom activity. */ removeTimeslot(slot: Timeslot): void { this.timeslots = this.timeslots.filter((slot_) => !slot_.equals(slot)); } @@ -176,7 +201,7 @@ export class NonClass { return res; } - /** Inflate a non-class activity with info from the output of deflate. */ + /** Inflate a custom activity with info from the output of deflate. */ inflate(parsed: (RawTimeslot[] | string)[]): void { const [timeslots, name, backgroundColor, room] = parsed; this.timeslots = (timeslots as RawTimeslot[]).map( @@ -191,5 +216,144 @@ export class NonClass { } } -/** Shared interface for Class and NonClass. */ -export type Activity = Class | NonClass; +/** + * A section is an array of timeslots that meet in the same room for the same + * purpose. Sections can be lectures, recitations, or labs, for a given class. + * All instances of Section belong to a Sections. + */ +export class Section { + /** Group of sections this section belongs to */ + secs: Sections; + /** Timeslots this section meets */ + timeslots: Timeslot[]; + /** String representing raw timeslots, e.g. MW9-11 or T2,F1. */ + rawTime: string; + /** Room this section meets in */ + room: string; + + /** @param section - raw section info (timeslot and room) */ + constructor(secs: Sections, rawTime: string, section: RawSection) { + this.secs = secs; + this.rawTime = rawTime; + const [rawSlots, room] = section; + this.timeslots = rawSlots.map((slot) => new Timeslot(...slot)); + this.room = room; + } + + /** Get the parsed time for this section in a format similar to the Registrar. */ + get parsedTime(): string { + const [room, days, eveningBool, times] = this.rawTime.split("/"); + + const isEvening = eveningBool === "1"; + + if (isEvening) { + return `${days} EVE (${times}) (${room})`; + } + + return `${days}${times} (${room})`; + } + + /** + * @param currentSlots - array of timeslots currently occupied + * @returns number of conflicts this section has with currentSlots + */ + countConflicts(currentSlots: Timeslot[]): number { + let conflicts = 0; + for (const slot of this.timeslots) { + for (const otherSlot of currentSlots) { + conflicts += slot.conflicts(otherSlot) ? 1 : 0; + } + } + return conflicts; + } +} + +/** The non-section options for a manual section time. */ +export const LockOption = { + Auto: "Auto", + None: "None", +} as const; + +/** The type of {@link LockOption}. */ +export type TLockOption = (typeof LockOption)[keyof typeof LockOption]; + +/** All section options for a manual section time. */ +export type SectionLockOption = Section | TLockOption; + +/** + * A group of {@link Section}s, all the same kind (like lec, rec, or lab). At + * most one of these can be selected at a time, and that selection is possibly + * locked. + */ +export class Sections { + cls: BaseActivity; + kind?: string; + sections: Section[]; + /** Are these sections locked? None counts as locked. */ + locked: boolean; + /** Currently selected section out of these. None is null. */ + selected: Section | null; + /** Overridden location for this particular section. */ + roomOverride = ""; + + constructor( + cls: BaseActivity, + rawTimes: string[], + secs: RawSection[], + kind?: string, + locked?: boolean, + selected?: Section | null, + ) { + this.cls = cls; + this.kind = kind; + this.sections = secs.map((sec, i) => new Section(this, rawTimes[i], sec)); + this.locked = locked ?? false; + this.selected = selected ?? null; + } + + /** Short name for the kind of sections these are. */ + get shortName(): string { + return this.kind ? this.kind.toLowerCase() : "sec"; + } + + private readonly _priority = 0; + get priority(): number { + return this._priority; + } + + /** Name for the kind of sections these are. */ + get name(): string { + return this.kind ?? "Section"; + } + + /** Full display name for this section on the calendar. */ + get longName(): string { + return `${this.cls.id} ${this.shortName}`; + } + + /** The event (possibly none) for this group of sections. */ + get event(): Event | null { + return this.selected + ? new Event( + this.cls, + this.longName, + this.selected.timeslots, + this.roomOverride || this.selected.room, + this.cls.half, + ) + : null; + } + + /** Lock a specific section of this class. Does not validate. */ + lockSection(sec: SectionLockOption): void { + if (sec === LockOption.Auto) { + this.locked = false; + } else if (sec === LockOption.None) { + this.locked = true; + this.selected = null; + } else { + this.locked = true; + this.selected = sec; + } + } +} diff --git a/src/lib/calendarSlots.ts b/src/lib/calendarSlots.ts index f426dae4..a5de166a 100644 --- a/src/lib/calendarSlots.ts +++ b/src/lib/calendarSlots.ts @@ -1,5 +1,6 @@ -import type { NonClass, Timeslot } from "./activity"; -import type { Section, Sections, Class } from "./class"; +import type { CustomActivity, Timeslot, Section, Sections } from "./activity"; +import type { Class } from "./class"; +import type { PEClass } from "./pe"; /** * Helper function for selectSlots. Implements backtracking: we try to place @@ -65,12 +66,13 @@ function selectHelper( * @returns Object with: * options - list of schedule options; each schedule option is a list of all * sections in that schedule, including locked sections (but not including - * non-class activities.) + * custom activities.) * conflicts - number of conflicts in any option */ export function scheduleSlots( selectedClasses: Class[], - selectedNonClasses: NonClass[], + selectedPEClasses: PEClass[], + selectedCustomActivities: CustomActivity[], ): { options: Section[][]; conflicts: number; @@ -97,7 +99,24 @@ export function scheduleSlots( } } - for (const activity of selectedNonClasses) { + for (const cls of selectedPEClasses) { + for (const secs of cls.sections) { + if (secs.locked) { + const sec = secs.selected; + if (sec) { + lockedSections.push(secs); + lockedOptions.push(sec); + initialSlots.push(...sec.timeslots); + } else { + // locked to having no section, do nothing + } + } else if (secs.sections.length > 0) { + freeSections.push(secs); + } + } + } + + for (const activity of selectedCustomActivities) { initialSlots.push(...activity.timeslots); } diff --git a/src/lib/class.ts b/src/lib/class.ts index 5067864d..fa076048 100644 --- a/src/lib/class.ts +++ b/src/lib/class.ts @@ -1,4 +1,4 @@ -import { Timeslot, Event } from "./activity"; +import { Event, Sections, type BaseActivity, type Section } from "./activity"; import type { ColorScheme } from "./colors"; import { fallbackColor } from "./colors"; import { @@ -10,7 +10,7 @@ import { TermCode, type RawClass, type RawSection, -} from "./rawClass"; +} from "./raw"; import nonextImg from "../assets/nonext.gif"; import underImg from "../assets/under.gif"; @@ -113,85 +113,9 @@ export const getFlagImg = (flag: keyof Flags): string => { return flagImages[flag] ?? ""; }; -/** - * A section is an array of timeslots that meet in the same room for the same - * purpose. Sections can be lectures, recitations, or labs, for a given class. - * All instances of Section belong to a Sections. - */ -export class Section { - /** Group of sections this section belongs to */ - secs: Sections; - /** Timeslots this section meets */ - timeslots: Timeslot[]; - /** String representing raw timeslots, e.g. MW9-11 or T2,F1. */ - rawTime: string; - /** Room this section meets in */ - room: string; - - /** @param section - raw section info (timeslot and room) */ - constructor(secs: Sections, rawTime: string, section: RawSection) { - this.secs = secs; - this.rawTime = rawTime; - const [rawSlots, room] = section; - this.timeslots = rawSlots.map((slot) => new Timeslot(...slot)); - this.room = room; - } - - /** Get the parsed time for this section in a format similar to the Registrar. */ - get parsedTime(): string { - const [room, days, eveningBool, times] = this.rawTime.split("/"); - - const isEvening = eveningBool === "1"; - - if (isEvening) { - return `${days} EVE (${times}) (${room})`; - } - - return `${days}${times} (${room})`; - } - - /** - * @param currentSlots - array of timeslots currently occupied - * @returns number of conflicts this section has with currentSlots - */ - countConflicts(currentSlots: Timeslot[]): number { - let conflicts = 0; - for (const slot of this.timeslots) { - for (const otherSlot of currentSlots) { - conflicts += slot.conflicts(otherSlot) ? 1 : 0; - } - } - return conflicts; - } -} - -/** The non-section options for a manual section time. */ -export const LockOption = { - Auto: "Auto", - None: "None", -} as const; - -/** The type of {@link LockOption}. */ -type TLockOption = (typeof LockOption)[keyof typeof LockOption]; - -/** All section options for a manual section time. */ -export type SectionLockOption = Section | TLockOption; - -/** - * A group of {@link Section}s, all the same kind (like lec, rec, or lab). At - * most one of these can be selected at a time, and that selection is possibly - * locked. - */ -export class Sections { - cls: Class; - kind: SectionKind; - sections: Section[]; - /** Are these sections locked? None counts as locked. */ - locked: boolean; - /** Currently selected section out of these. None is null. */ - selected: Section | null; - /** Overridden location for this particular section. */ - roomOverride = ""; +export class ClassSections extends Sections { + declare cls: Class; + declare kind: SectionKind; constructor( cls: Class, @@ -201,14 +125,10 @@ export class Sections { locked?: boolean, selected?: Section | null, ) { - this.cls = cls; + super(cls, rawTimes, secs, kind, locked, selected); this.kind = kind; - this.sections = secs.map((sec, i) => new Section(this, rawTimes[i], sec)); - this.locked = locked ?? false; - this.selected = selected ?? null; } - /** Short name for the kind of sections these are. */ get shortName(): string { switch (this.kind) { case SectionKind.LECTURE: @@ -235,7 +155,6 @@ export class Sections { } } - /** Name for the kind of sections these are. */ get name(): string { switch (this.kind) { case SectionKind.LECTURE: @@ -248,43 +167,17 @@ export class Sections { return "Design"; } } - - /** The event (possibly none) for this group of sections. */ - get event(): Event | null { - return this.selected - ? new Event( - this.cls, - `${this.cls.number} ${this.shortName}`, - this.selected.timeslots, - this.roomOverride || this.selected.room, - this.cls.half, - ) - : null; - } - - /** Lock a specific section of this class. Does not validate. */ - lockSection(sec: SectionLockOption): void { - if (sec === LockOption.Auto) { - this.locked = false; - } else if (sec === LockOption.None) { - this.locked = true; - this.selected = null; - } else { - this.locked = true; - this.selected = sec; - } - } } /** An entire class, e.g. 6.036, and its selected sections. */ -export class Class { +export class Class implements BaseActivity { /** * The RawClass being wrapped around. Nothing outside Class should touch * this; instead use the Class getters like cls.id, cls.number, etc. */ readonly rawClass: RawClass; /** The sections associated with this class. */ - readonly sections: Sections[]; + readonly sections: ClassSections[]; /** The background color for the class, used for buttons and calendar. */ backgroundColor: string; /** Is the color set by the user (as opposed to chosen automatically?) */ @@ -298,28 +191,28 @@ export class Class { .map((kind) => { switch (kind) { case SectionKind.LECTURE: - return new Sections( + return new ClassSections( this, SectionKind.LECTURE, rawClass.lectureRawSections, rawClass.lectureSections, ); case SectionKind.RECITATION: - return new Sections( + return new ClassSections( this, SectionKind.RECITATION, rawClass.recitationRawSections, rawClass.recitationSections, ); case SectionKind.LAB: - return new Sections( + return new ClassSections( this, SectionKind.LAB, rawClass.labRawSections, rawClass.labSections, ); case SectionKind.DESIGN: - return new Sections( + return new ClassSections( this, SectionKind.DESIGN, rawClass.designRawSections, @@ -411,6 +304,14 @@ export class Class { .filter((event): event is Event => event instanceof Event); } + get start(): [number, number] | undefined { + return this.rawClass.quarterInfo?.start; + } + + get end(): [number, number] | undefined { + return this.rawClass.quarterInfo?.end; + } + /** Object of boolean properties of class, used for filtering. */ get flags(): Flags { return { diff --git a/src/lib/dates.ts b/src/lib/dates.ts index 207f2c1e..8ca1b753 100644 --- a/src/lib/dates.ts +++ b/src/lib/dates.ts @@ -1,4 +1,4 @@ -import { TermCode } from "./rawClass"; +import { TermCode } from "./raw"; /** Dictionary of semester-name related constants. */ const SEMESTER_NAMES = { diff --git a/src/lib/gapi.ts b/src/lib/gapi.ts index d56effa6..eaacc00b 100644 --- a/src/lib/gapi.ts +++ b/src/lib/gapi.ts @@ -6,6 +6,7 @@ import { tzlib_get_ical_block } from "timezones-ical-library"; import type { Activity } from "./activity"; import type { Term } from "./dates"; import type { State } from "./state"; +import { Class } from "./class"; /** Timezone string. */ const TIMEZONE = "America/New_York"; @@ -30,13 +31,21 @@ function download(filename: string, text: string) { function toICalEvents(activity: Activity, term: Term): ICalEventData[] { return activity.events.flatMap((event) => event.slots.map((slot) => { - const rawClass = - "rawClass" in event.activity ? event.activity.rawClass : undefined; - - const start = rawClass?.quarterInfo?.start; - const end = rawClass?.quarterInfo?.end; - const h1 = rawClass?.half === 1; - const h2 = rawClass?.half === 2; + const start: [number, number] | undefined = + "start" in event.activity ? event.activity.start : undefined; + const end: [number, number] | undefined = + "end" in event.activity ? event.activity.end : undefined; + + let h1 = false; + let h2 = false; + + if (event.activity instanceof Class) { + if (event.half === 1) { + h1 = true; + } else if (event.half === 2) { + h2 = true; + } + } const startDate = term.startDateFor(slot.startSlot, h2, start); const startDateEnd = term.startDateFor(slot.endSlot, h2, start); diff --git a/src/lib/hydrant.ts b/src/lib/hydrant.ts index e5eff8a4..923e40bb 100644 --- a/src/lib/hydrant.ts +++ b/src/lib/hydrant.ts @@ -2,7 +2,7 @@ import { createContext, useEffect, useRef, useState } from "react"; import { useColorMode } from "../components/ui/color-mode"; import type { TermInfo } from "../lib/dates"; -import type { RawClass } from "../lib/rawClass"; +import type { RawClass, RawPEClass } from "./raw"; import type { HydrantState } from "../lib/schema"; import { DEFAULT_STATE } from "../lib/schema"; import type { State } from "../lib/state"; @@ -11,8 +11,24 @@ export interface SemesterData { classes: Record; lastUpdated: string; termInfo: TermInfo; + pe?: Record>; } +export const getStateMaps = ( + classes: SemesterData["classes"], + pe?: SemesterData["pe"], +) => { + const classesMap = new Map(Object.entries(classes)); + const peClassesMap = Object.entries(pe ?? {}).reduce< + Record> + >((acc, [quarter, peClasses]) => { + acc[Number(quarter)] = new Map(Object.entries(peClasses)); + return acc; + }, {}); + + return { classesMap, peClassesMap }; +}; + /** Fetch from the url, which is JSON of type T. */ export const fetchNoCache = async (url: string): Promise => { const res = await fetch(url, { cache: "no-cache" }); diff --git a/src/lib/pe.ts b/src/lib/pe.ts new file mode 100644 index 00000000..c975ea9f --- /dev/null +++ b/src/lib/pe.ts @@ -0,0 +1,211 @@ +import { Section, Sections, type BaseActivity } from "./activity"; +import { type RawPEClass, type RawSection } from "./raw"; +import { Event } from "./activity"; +import { fallbackColor, type ColorScheme } from "./colors"; + +export interface PEFlags { + wellness: boolean; + pirate: boolean; + swim: boolean; + nofee: boolean; + nopreq: boolean; +} + +const peFlagEmojis: { [k in keyof PEFlags]?: string } = { + wellness: "🔮", + pirate: "🏴‍☠️", + swim: "🌊", +}; + +export const getPEFlagEmoji = (flag: keyof PEFlags): string => { + return peFlagEmojis[flag] ?? ""; +}; + +export class PESection extends Section { + sectionNumber: string; + + constructor( + secs: PESections, + rawTime: string, + section: RawSection, + sectionNumber: string, + ) { + super(secs, rawTime, section); + this.sectionNumber = sectionNumber; + } +} + +export class PESections extends Sections { + declare cls: PEClass; + declare sections: PESection[]; + declare selected: PESection | null; + + constructor( + cls: BaseActivity, + rawTimes: string[], + secs: RawSection[], + sectionNumbers: string[], + kind?: string, + locked?: boolean, + selected?: Section | null, + ) { + super(cls, rawTimes, secs, kind, locked, selected); + this.sections = secs.map( + (sec, i) => new PESection(this, rawTimes[i], sec, sectionNumbers[i]), + ); + } + + public get longName() { + return this.selected + ? `${this.cls.id}-${this.selected.sectionNumber}` + : this.cls.id; + } + + get priority(): number { + return -1; + } +} + +/** + * PE&W activity placeholder + */ +export class PEClass implements BaseActivity { + backgroundColor: string; + manualColor = false; + readonly rawClass: RawPEClass; + readonly sections: PESections[]; + + constructor(rawClass: RawPEClass, colorScheme: ColorScheme) { + this.rawClass = rawClass; + this.backgroundColor = fallbackColor(colorScheme); + this.sections = [ + new PESections( + this, + rawClass.rawSections, + rawClass.sections, + rawClass.sectionNumbers, + ), + ]; + } + + get id(): string { + return this.rawClass.number; + } + + /** Hours per week. */ + get hours(): number { + return this.rawClass.points; + } + + get warnings(): { + suffix: string; + messages: string[]; + } { + return { + suffix: "%", + messages: [ + "% PE classes don't have evaluations, so hours were set to PE point values.", + ], + }; + } + + /** Name that appears when it's on a button. */ + get buttonName(): string { + return `${this.rawClass.number}${this.warnings.suffix}`; + } + + get startDate() { + return new Date(this.rawClass.startDate); + } + + get endDate() { + return new Date(this.rawClass.endDate); + } + + /** Fee, in dollars */ + get fee(): number { + const fee = this.rawClass.fee; + if (!fee.startsWith("$")) { + console.error("Fee not in dollars:", fee); + } + const feeAmt = Number(fee.slice(1)); + if (isNaN(feeAmt)) { + console.error("Non-numerical fee:", fee); + } + return feeAmt; + } + + get events(): Event[] { + return this.sections + .map((secs) => secs.event) + .filter((event): event is Event => event instanceof Event); + } + + get start(): [number, number] { + const startDate = new Date(this.rawClass.startDate); + return [startDate.getMonth() + 1, startDate.getDate()]; + } + + get end(): [number, number] { + const endDate = new Date(this.rawClass.endDate); + return [endDate.getMonth() + 1, endDate.getDate()]; + } + + get flags(): PEFlags { + return { + wellness: this.rawClass.wellness, + pirate: this.rawClass.pirate, + swim: this.rawClass.swimGIR, + nofee: this.rawClass.fee == "$0.00", + nopreq: this.rawClass.prereqs == "None", + }; + } + + /** Deflate a class to something JSONable. */ + deflate() { + const sections = this.sections.map((secs) => + !secs.locked + ? null + : secs.sections.findIndex((sec) => sec === secs.selected), + ); + const sectionLocs = this.sections.map((secs) => secs.roomOverride); + while (sections.at(-1) === null) sections.pop(); + return [ + this.id, + ...(this.manualColor ? [this.backgroundColor] : []), // string + ...(sectionLocs.length ? [sectionLocs] : []), // array[string] + ...(sections.length > 0 ? (sections as number[]) : []), // number + ]; + } + + inflate(parsed: string | (string | number | string[])[]): void { + if (typeof parsed === "string") { + // just the class id, ignore + return; + } + // we ignore parsed[0] as that has the class id + let offset = 1; + if (typeof parsed[1] === "string") { + offset += 1; + this.backgroundColor = parsed[1]; + this.manualColor = true; + } + let sectionLocs: (string | number | string[])[] | null = null; + if (Array.isArray(parsed[offset])) { + sectionLocs = parsed[offset] as string[]; + offset += 1; + } + this.sections.forEach((secs, i) => { + if (sectionLocs && typeof sectionLocs[i] === "string") { + secs.roomOverride = sectionLocs[i]; + } + const parse = parsed[i + offset]; + if (!parse && parse !== 0) { + secs.locked = false; + } else { + secs.locked = true; + secs.selected = secs.sections[parse as number]; + } + }); + } +} diff --git a/src/lib/rawClass.ts b/src/lib/raw.ts similarity index 81% rename from src/lib/rawClass.ts rename to src/lib/raw.ts index 0e2565b5..e1815741 100644 --- a/src/lib/rawClass.ts +++ b/src/lib/raw.ts @@ -198,3 +198,45 @@ export enum TermCode { /** Summer */ SU = "SU", } + +export interface RawPEClass { + /** Class number; e.g., "PE.0612" */ + number: string; + /** Class name; e.g., "Skate, Beginner" */ + name: string; + + /** PE&W section number for each section */ + sectionNumbers: string[]; + /** Timeslots and locations for each section */ + sections: RawSection[]; + /** Raw (FireRoad format) section locations/times */ + rawSections: string[]; + /** Class size (for each section) */ + classSize: number; + + /** Start date, in ISO 8601 format */ + startDate: string; + /** End date, in ISO 8601 format */ + endDate: string; + + /** PE points */ + points: number; + + /** Wellness Wizard eligible */ + wellness: boolean; + /** Pirate Certificate eligible */ + pirate: boolean; + /** Satisfies swim GIR */ + swimGIR: boolean; + + /** Prereqs, no specific format */ + prereqs: string; + /** Equipment, no specific format */ + equipment: string; + /** Fee */ + fee: string; + /** Description, no specific format */ + description: string; + /** Quarter of class */ + quarter: number; +} diff --git a/src/lib/schema.ts b/src/lib/schema.ts index f698381c..ef42bbae 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -1,8 +1,15 @@ import type { Activity } from "./activity"; import type { ColorScheme } from "./colors"; +export enum ClassType { + ACADEMIC = "Academic", + PEW = "PE & Wellness", +} + /** The date the content of the banner was last changed. */ -export const BANNER_LAST_CHANGED = new Date("2025-11-24T17:15:00Z").valueOf(); +export const BANNER_LAST_CHANGED = new Date("2026-01-23T12:00:00Z").valueOf(); +export const BANNER_MESSAGE = + "Q3 Physical Education and Wellness classes are now available on Hydrant! Registration opens Jan 30 on the PE&W website."; /** A save has an ID and a name. */ export interface Save { diff --git a/src/lib/state.ts b/src/lib/state.ts index 10911642..8fc1369b 100644 --- a/src/lib/state.ts +++ b/src/lib/state.ts @@ -1,18 +1,24 @@ import { nanoid } from "nanoid"; -import type { Timeslot, Activity } from "./activity"; -import { NonClass } from "./activity"; +import type { + Timeslot, + Activity, + Section, + SectionLockOption, + Sections, +} from "./activity"; +import { CustomActivity } from "./activity"; import { scheduleSlots } from "./calendarSlots"; -import type { Section, SectionLockOption, Sections } from "./class"; import { Class } from "./class"; import type { Term } from "./dates"; import type { ColorScheme } from "./colors"; import { chooseColors, fallbackColor, getDefaultColorScheme } from "./colors"; -import type { RawClass, RawTimeslot } from "./rawClass"; +import type { RawClass, RawTimeslot, RawPEClass } from "./raw"; import { Store } from "./store"; import { sum, urldecode, urlencode } from "./utils"; import type { HydrantState, Preferences, Save } from "./schema"; import { BANNER_LAST_CHANGED, DEFAULT_PREFERENCES } from "./schema"; +import { PEClass } from "./pe"; /** * Global State object. Maintains global program state (selected classes, @@ -21,6 +27,8 @@ import { BANNER_LAST_CHANGED, DEFAULT_PREFERENCES } from "./schema"; export class State { /** Map from class number to Class object. */ classes: Map; + /** Map from class number to PEClass object. */ + peClasses: Map; /** Possible section choices. */ options: Section[][] = [[]]; /** Current number of schedule conflicts. */ @@ -41,8 +49,10 @@ export class State { private viewedActivity: Activity | undefined; /** Selected class activities. */ private selectedClasses: Class[] = []; - /** Selected non-class activities. */ - private selectedNonClasses: NonClass[] = []; + /** Selected PE and Wellness classes. */ + private selectedPEClasses: PEClass[] = []; + /** Selected custom activities. */ + private selectedCustomActivities: CustomActivity[] = []; /** Selected schedule option; zero-indexed. */ private selectedOption = 0; /** Currently loaded save slot, empty if none of them. */ @@ -53,6 +63,8 @@ export class State { private preferences: Preferences = DEFAULT_PREFERENCES; /** Set of starred class numbers */ private starredClasses = new Set(); + /** Set of starred PE class numbers */ + private starredPEClasses = new Set(); /** React callback to update state. */ callback: ((state: HydrantState) => void) | undefined; @@ -61,6 +73,7 @@ export class State { constructor( rawClasses: Map, + rawPEClasses: Record>, /** The current term object. */ public readonly term: Term, /** String representing last update time. */ @@ -69,16 +82,26 @@ export class State { public readonly latestUrlName: string, ) { this.classes = new Map(); + this.peClasses = new Map(); this.store = new Store(term.toString()); rawClasses.forEach((cls, number) => { this.classes.set(number, new Class(cls, this.colorScheme)); }); + Object.values(rawPEClasses).forEach((map) => { + map.forEach((cls, number) => { + this.peClasses.set(number, new PEClass(cls, this.colorScheme)); + }); + }); this.initState(); } /** All activities. */ get selectedActivities(): Activity[] { - return [...this.selectedClasses, ...this.selectedNonClasses]; + return [ + ...this.selectedClasses, + ...this.selectedPEClasses, + ...this.selectedCustomActivities, + ]; } /** The color scheme. */ @@ -110,17 +133,21 @@ export class State { /** * Adds an activity, selects it, and updates. * - * @param activity - Activity to be added. If null, creates a new NonClass + * @param activity - Activity to be added. If null, creates a new CustomActivity * and adds it. */ addActivity(activity?: Activity): void { - const toAdd = activity ?? new NonClass(this.colorScheme); + const toAdd = activity ?? new CustomActivity(this.colorScheme); this.setViewedActivity(toAdd); if (this.isSelectedActivity(toAdd)) return; if (toAdd instanceof Class) { this.selectedClasses.push(toAdd); - } else { - this.selectedNonClasses.push(toAdd); + } + if (toAdd instanceof PEClass) { + this.selectedPEClasses.push(toAdd); + } + if (toAdd instanceof CustomActivity) { + this.selectedCustomActivities.push(toAdd); } this.updateActivities(); } @@ -132,8 +159,12 @@ export class State { this.selectedClasses = this.selectedClasses.filter( (activity_) => activity_.id !== activity.id, ); + } else if (activity instanceof PEClass) { + this.selectedPEClasses = this.selectedPEClasses.filter( + (activity_) => activity_.id !== activity.id, + ); } else { - this.selectedNonClasses = this.selectedNonClasses.filter( + this.selectedCustomActivities = this.selectedCustomActivities.filter( (activity_) => activity_.id !== activity.id, ); this.setViewedActivity(undefined); @@ -165,41 +196,44 @@ export class State { } //======================================================================== - // NonClass handlers + // CustomActivity handlers /** Rename a given non-activity. */ - renameNonClass(nonClass: NonClass, name: string): void { - const nonClass_ = this.selectedNonClasses.find( - (nonClass_) => nonClass_.id === nonClass.id, + renameCustomActivity(customActivity: CustomActivity, name: string): void { + const customActivity_ = this.selectedCustomActivities.find( + (customActivity_) => customActivity_.id === customActivity.id, ); - if (!nonClass_) return; + if (!customActivity_) return; - nonClass_.name = name; + customActivity_.name = name; this.updateState(); } - /** Changes the room for a given non-class. */ - relocateNonClass(nonClass: NonClass, room: string | undefined): void { - const nonClass_ = this.selectedNonClasses.find( - (nonClass_) => nonClass_.id === nonClass.id, + /** Changes the room for a given custom activity. */ + relocateCustomActivity( + customActivity: CustomActivity, + room: string | undefined, + ): void { + const customActivity_ = this.selectedCustomActivities.find( + (customActivity_) => customActivity_.id === customActivity.id, ); - if (!nonClass_) return; + if (!customActivity_) return; - nonClass_.room = room; + customActivity_.room = room; this.updateState(); } /** Add the timeslot to currently viewed activity. */ - addTimeslot(nonClass: NonClass, slot: Timeslot): void { - nonClass.addTimeslot(slot); + addTimeslot(customActivity: CustomActivity, slot: Timeslot): void { + customActivity.addTimeslot(slot); this.updateActivities(); } /** Remove all equal timeslots from currently viewed activity. */ - removeTimeslot(nonClass: NonClass, slot: Timeslot): void { - nonClass.removeTimeslot(slot); + removeTimeslot(customActivity: CustomActivity, slot: Timeslot): void { + customActivity.removeTimeslot(slot); this.updateActivities(); } @@ -219,7 +253,11 @@ export class State { units: sum(this.selectedClasses.map((cls) => cls.totalUnits)), hours: sum(this.selectedActivities.map((activity) => activity.hours)), warnings: Array.from( - new Set(this.selectedClasses.flatMap((cls) => cls.warnings.messages)), + new Set( + this.selectedActivities.flatMap((cls) => + "warnings" in cls ? cls.warnings.messages : [], + ), + ), ), saveId: this.saveId, saves: this.saves, @@ -248,7 +286,11 @@ export class State { */ updateActivities(save = true): void { chooseColors(this.selectedActivities, this.colorScheme); - const result = scheduleSlots(this.selectedClasses, this.selectedNonClasses); + const result = scheduleSlots( + this.selectedClasses, + this.selectedPEClasses, + this.selectedCustomActivities, + ); this.options = result.options; this.conflicts = result.conflicts; this.selectOption(); @@ -261,15 +303,17 @@ export class State { * Used for the "fits schedule" filter in ClassTable. Might be slow; careful * with using this too frequently. */ - fitsSchedule(cls: Class): boolean { + fitsSchedule(cls: Class | PEClass): boolean { return ( !this.isSelectedActivity(cls) && (cls.sections.length === 0 || (this.selectedClasses.length === 0 && - this.selectedNonClasses.length === 0) || + this.selectedPEClasses.length === 0 && + this.selectedCustomActivities.length === 0) || scheduleSlots( - this.selectedClasses.concat([cls]), - this.selectedNonClasses, + this.selectedClasses.concat(cls instanceof Class ? [cls] : []), + this.selectedPEClasses.concat(cls instanceof PEClass ? [cls] : []), + this.selectedCustomActivities, ).conflicts === this.conflicts) ); } @@ -283,10 +327,10 @@ export class State { /** Star or unstar a class */ toggleStarClass(cls: Class): void { - if (this.starredClasses.has(cls.number)) { - this.starredClasses.delete(cls.number); + if (this.starredClasses.has(cls.id)) { + this.starredClasses.delete(cls.id); } else { - this.starredClasses.add(cls.number); + this.starredClasses.add(cls.id); } this.store.set("starredClasses", Array.from(this.starredClasses)); this.updateState(); @@ -294,16 +338,39 @@ export class State { /** Check if a class is starred */ isClassStarred(cls: Class): boolean { - return this.starredClasses.has(cls.number); + return this.starredClasses.has(cls.id); } /** Get all starred classes */ getStarredClasses(): Class[] { return Array.from(this.starredClasses) - .map((number) => this.classes.get(number)) + .map((id) => this.classes.get(id)) .filter((cls): cls is Class => cls !== undefined); } + /** Star or unstar a class */ + toggleStarPEClass(cls: PEClass): void { + if (this.starredPEClasses.has(cls.id)) { + this.starredPEClasses.delete(cls.id); + } else { + this.starredPEClasses.add(cls.id); + } + this.store.set("starredPEClasses", Array.from(this.starredPEClasses)); + this.updateState(); + } + + /** Check if a class is starred */ + isPEClassStarred(cls: PEClass): boolean { + return this.starredPEClasses.has(cls.id); + } + + /** Get all starred classes */ + getStarredPEClasses(): PEClass[] { + return Array.from(this.starredPEClasses) + .map((id) => this.peClasses.get(id)) + .filter((cls): cls is PEClass => cls !== undefined); + } + get showBanner(): boolean { return ( this.preferences.showBanner || @@ -324,7 +391,7 @@ export class State { /** Clear (almost) all program state. This doesn't clear class state. */ reset(): void { this.selectedClasses = []; - this.selectedNonClasses = []; + this.selectedCustomActivities = []; this.selectedOption = 0; } @@ -332,10 +399,13 @@ export class State { deflate() { return [ this.selectedClasses.map((cls) => cls.deflate()), - this.selectedNonClasses.length > 0 - ? this.selectedNonClasses.map((nonClass) => nonClass.deflate()) + this.selectedCustomActivities.length > 0 + ? this.selectedCustomActivities.map((customActivity) => + customActivity.deflate(), + ) : null, this.selectedOption, + this.selectedPEClasses.map((cls) => cls.deflate()), ]; } @@ -352,10 +422,11 @@ export class State { ): void { if (!obj) return; this.reset(); - const [classes, nonClasses, selectedOption] = obj as [ + const [classes, customActivities, selectedOption, peClasses] = obj as [ (string | number | string[])[][], (string | RawTimeslot[])[][] | null, number | undefined, + (string | number | string[])[][] | undefined, // undefined for backwards compatability ]; for (const deflated of classes) { const cls = @@ -366,14 +437,23 @@ export class State { cls.inflate(deflated); this.selectedClasses.push(cls); } - if (nonClasses) { - for (const deflated of nonClasses) { - const nonClass = new NonClass(this.colorScheme); - nonClass.inflate(deflated); - this.selectedNonClasses.push(nonClass); + if (customActivities) { + for (const deflated of customActivities) { + const customActivity = new CustomActivity(this.colorScheme); + customActivity.inflate(deflated); + this.selectedCustomActivities.push(customActivity); } } this.selectedOption = selectedOption ?? 0; + for (const deflated of peClasses ?? []) { + const cls = + typeof deflated === "string" + ? this.peClasses.get(deflated) + : this.peClasses.get((deflated as string[])[0]); + if (!cls) continue; + cls.inflate(deflated); + this.selectedPEClasses.push(cls); + } this.saveId = ""; this.updateActivities(false); } diff --git a/src/lib/utils.tsx b/src/lib/utils.tsx index 23caed2c..3e250154 100644 --- a/src/lib/utils.tsx +++ b/src/lib/utils.tsx @@ -25,12 +25,17 @@ const CLASS_REGEX = new RegExp( /** Three-way comparison for class numbers. */ export function classSort( - a: string | null | undefined, - b: string | null | undefined, + a: string | number | null | undefined, + b: string | number | null | undefined, ) { if (!a && !b) return 0; if (!a) return 1; if (!b) return -1; + if (typeof a === "number" && typeof b === "number") { + return a - b; + } + a = String(a); + b = String(b); const aGroups = CLASS_REGEX.exec(a)?.groups; const bGroups = CLASS_REGEX.exec(b)?.groups; if (!aGroups || !bGroups) return 0; diff --git a/src/routes/_index.tsx b/src/routes/_index.tsx index 4d745662..a4fe2d98 100644 --- a/src/routes/_index.tsx +++ b/src/routes/_index.tsx @@ -1,29 +1,27 @@ -import { useState, useContext } from "react"; - -import { Center, Flex, Group, Button, ButtonGroup } from "@chakra-ui/react"; -import { Tooltip } from "../components/ui/tooltip"; -import { ActivityDescription } from "../components/ActivityDescription"; +import { Center, Flex, Group, ButtonGroup } from "@chakra-ui/react"; import { Calendar } from "../components/Calendar"; -import { ClassTable } from "../components/ClassTable"; import { LeftFooter } from "../components/Footers"; import { Header, PreferencesDialog } from "../components/Header"; +import { SelectedActivities } from "../components/SelectedActivities"; import { ScheduleOption } from "../components/ScheduleOption"; import { ScheduleSwitcher } from "../components/ScheduleSwitcher"; -import { SelectedActivities } from "../components/SelectedActivities"; import { TermSwitcher } from "../components/TermSwitcher"; import { Banner } from "../components/Banner"; -import { MatrixLink } from "../components/MatrixLink"; -import { PreregLink } from "../components/PreregLink"; -import { LuCalendarArrowDown } from "react-icons/lu"; +import { + MatrixLink, + PreregLink, + ExportCalendar, +} from "../components/ButtonsLinks"; +import { ClassTypesSwitcher } from "../components/ClassTypes"; import { State } from "../lib/state"; import { Term } from "../lib/dates"; -import { useICSExport } from "../lib/gapi"; -import type { SemesterData } from "../lib/hydrant"; +import { type SemesterData, getStateMaps } from "../lib/hydrant"; import { useHydrant, HydrantContext, fetchNoCache } from "../lib/hydrant"; import { getClosestUrlName, type LatestTermInfo } from "../lib/dates"; import type { Route } from "./+types/_index"; +import { ActivityDescription } from "~/components/ActivityDescription"; // eslint-disable-next-line react-refresh/only-export-components export async function clientLoader({ request }: Route.ClientLoaderArgs) { @@ -55,14 +53,16 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { window.location.search = searchParams.toString(); } - const { classes, lastUpdated, termInfo } = await fetchNoCache( - `${import.meta.env.BASE_URL}${termToFetch}.json`, - ); - const classesMap = new Map(Object.entries(classes)); + const { classes, lastUpdated, termInfo, pe } = + await fetchNoCache( + `${import.meta.env.BASE_URL}${termToFetch}.json`, + ); + const { classesMap, peClassesMap } = getStateMaps(classes, pe); return { globalState: new State( classesMap, + peClassesMap, new Term(termInfo), lastUpdated, latestTerm.semester.urlName, @@ -72,20 +72,6 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { /** The application entry. */ function HydrantApp() { - const { state } = useContext(HydrantContext); - - const [isExporting, setIsExporting] = useState(false); - // TODO: fix gcal export - const onICSExport = useICSExport( - state, - () => { - setIsExporting(false); - }, - () => { - setIsExporting(false); - }, - ); - return ( <> @@ -108,28 +94,13 @@ function HydrantApp() {
- - - +
- +
diff --git a/src/routes/export.ts b/src/routes/export.ts index 78d21cfa..150ec52f 100644 --- a/src/routes/export.ts +++ b/src/routes/export.ts @@ -1,6 +1,6 @@ import { redirect } from "react-router"; -import { fetchNoCache, type SemesterData } from "../lib/hydrant"; +import { fetchNoCache, type SemesterData, getStateMaps } from "../lib/hydrant"; import { getClosestUrlName, Term, type LatestTermInfo } from "../lib/dates"; import { State } from "../lib/state"; import { Class } from "../lib/class"; @@ -35,12 +35,13 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { const term = urlName === latestTerm.semester.urlName ? "latest" : urlName; - const { classes, lastUpdated, termInfo } = await fetchNoCache( - `${import.meta.env.BASE_URL}${term}.json`, - ); - const classesMap = new Map(Object.entries(classes)); + const { classes, lastUpdated, termInfo, pe } = + await fetchNoCache(`${import.meta.env.BASE_URL}${term}.json`); + const { classesMap, peClassesMap } = getStateMaps(classes, pe); + const hydrantObj = new State( classesMap, + peClassesMap, new Term(termInfo), lastUpdated, latestTerm.semester.urlName, diff --git a/tests/activity.test.ts b/tests/activity.test.ts index e54dbdb1..a38a71a9 100644 --- a/tests/activity.test.ts +++ b/tests/activity.test.ts @@ -1,5 +1,5 @@ import { expect, test, describe } from "vitest"; -import { Timeslot, NonClass, Event } from "../src/lib/activity"; +import { Timeslot, CustomActivity, Event } from "../src/lib/activity"; import { Slot } from "../src/lib/dates"; import { COLOR_SCHEME_LIGHT, @@ -121,13 +121,15 @@ describe("Timeslot", () => { }); test("Event.eventInputs", () => { - const myNonClass: NonClass = new NonClass(COLOR_SCHEME_LIGHT_CONTRAST); + const myCustomActivity: CustomActivity = new CustomActivity( + COLOR_SCHEME_LIGHT_CONTRAST, + ); const myHexCode = "#611917"; // randomly generated hex code const myTitle = "y8g0i81"; // random keysmashes const myRoom = "ahouttoanhontjanota"; - myNonClass.backgroundColor = myHexCode; + myCustomActivity.backgroundColor = myHexCode; const myEvent: Event = new Event( - myNonClass, + myCustomActivity, myTitle, [new Timeslot(6, 7), new Timeslot(57, 10)], myRoom, @@ -142,7 +144,7 @@ test("Event.eventInputs", () => { backgroundColor: myHexCode, borderColor: myHexCode, room: myRoom, - activity: myNonClass, + activity: myCustomActivity, }, { textColor: "#ffffff", @@ -152,74 +154,90 @@ test("Event.eventInputs", () => { backgroundColor: myHexCode, borderColor: myHexCode, room: myRoom, - activity: myNonClass, + activity: myCustomActivity, }, ]); }); -describe("NonClass", () => { - describe("NonClass.constructor", () => { +describe("CustomActivity", () => { + describe("CustomActivity.constructor", () => { const nanoidRegex = /^[A-Za-z0-9-_]{8}$/; test("COLOR_SCHEME_LIGHT", () => { - const myNonClass: NonClass = new NonClass(COLOR_SCHEME_LIGHT); - expect(nanoidRegex.test(myNonClass.id)).toBeTruthy(); - expect(myNonClass.backgroundColor).toBe("#4A5568"); + const myCustomActivity: CustomActivity = new CustomActivity( + COLOR_SCHEME_LIGHT, + ); + expect(nanoidRegex.test(myCustomActivity.id)).toBeTruthy(); + expect(myCustomActivity.backgroundColor).toBe("#4A5568"); }); test("COLOR_SCHEME_DARK", () => { - const myNonClass: NonClass = new NonClass(COLOR_SCHEME_DARK); - expect(nanoidRegex.test(myNonClass.id)).toBeTruthy(); - expect(myNonClass.backgroundColor).toBe("#CBD5E0"); + const myCustomActivity: CustomActivity = new CustomActivity( + COLOR_SCHEME_DARK, + ); + expect(nanoidRegex.test(myCustomActivity.id)).toBeTruthy(); + expect(myCustomActivity.backgroundColor).toBe("#CBD5E0"); }); test("COLOR_SCHEME_LIGHT_CONTRAST", () => { - const myNonClass: NonClass = new NonClass(COLOR_SCHEME_LIGHT_CONTRAST); - expect(nanoidRegex.test(myNonClass.id)).toBeTruthy(); - expect(myNonClass.backgroundColor).toBe("#4A5568"); + const myCustomActivity: CustomActivity = new CustomActivity( + COLOR_SCHEME_LIGHT_CONTRAST, + ); + expect(nanoidRegex.test(myCustomActivity.id)).toBeTruthy(); + expect(myCustomActivity.backgroundColor).toBe("#4A5568"); }); test("COLOR_SCHEME_DARK_CONTRAST", () => { - const myNonClass: NonClass = new NonClass(COLOR_SCHEME_DARK_CONTRAST); - expect(nanoidRegex.test(myNonClass.id)).toBeTruthy(); - expect(myNonClass.backgroundColor).toBe("#CBD5E0"); + const myCustomActivity: CustomActivity = new CustomActivity( + COLOR_SCHEME_DARK_CONTRAST, + ); + expect(nanoidRegex.test(myCustomActivity.id)).toBeTruthy(); + expect(myCustomActivity.backgroundColor).toBe("#CBD5E0"); }); }); - describe("NonClass.buttonName", () => { + describe("CustomActivity.buttonName", () => { /** Partition on this.name: changed, not changed */ - test("NonClass.name not changed", () => { - expect(new NonClass(COLOR_SCHEME_LIGHT).buttonName).toBe("New Activity"); + test("CustomActivity.name not changed", () => { + expect(new CustomActivity(COLOR_SCHEME_LIGHT).buttonName).toBe( + "New Activity", + ); }); - test("NonClass.name changed", () => { - const myNonClass: NonClass = new NonClass(COLOR_SCHEME_LIGHT); + test("CustomActivity.name changed", () => { + const myCustomActivity: CustomActivity = new CustomActivity( + COLOR_SCHEME_LIGHT, + ); const myString = "lorem ipsum dolor sit amet"; - myNonClass.name = myString; - expect(myNonClass.buttonName).toBe(myString); + myCustomActivity.name = myString; + expect(myCustomActivity.buttonName).toBe(myString); }); }); - describe("NonClass.hours", () => { + describe("CustomActivity.hours", () => { /** Partition on timeslot count: 0, 1, >1 */ test("0 timeslots", () => { - expect(new NonClass(COLOR_SCHEME_LIGHT).hours).toBe(0); + expect(new CustomActivity(COLOR_SCHEME_LIGHT).hours).toBe(0); }); test("1 timeslot", () => { - const myNonClass: NonClass = new NonClass(COLOR_SCHEME_LIGHT); - myNonClass.timeslots = [new Timeslot(4, 5)]; - expect(myNonClass.hours).toBe(5 / 2); + const myCustomActivity: CustomActivity = new CustomActivity( + COLOR_SCHEME_LIGHT, + ); + myCustomActivity.timeslots = [new Timeslot(4, 5)]; + expect(myCustomActivity.hours).toBe(5 / 2); }); test("multiple timeslots", () => { - const myNonClass: NonClass = new NonClass(COLOR_SCHEME_LIGHT); - myNonClass.timeslots = [new Timeslot(2, 7), new Timeslot(11, 5)]; - expect(myNonClass.hours).toBe(6); + const myCustomActivity: CustomActivity = new CustomActivity( + COLOR_SCHEME_LIGHT, + ); + myCustomActivity.timeslots = [new Timeslot(2, 7), new Timeslot(11, 5)]; + expect(myCustomActivity.hours).toBe(6); }); }); - test("NonClass.events", () => { + test("CustomActivity.events", () => { // arbitrary random constants const myName = "r57t68y9u"; const myTimeslots: Timeslot[] = [ @@ -228,90 +246,104 @@ describe("NonClass", () => { new Timeslot(21, 32), ]; const myRoom = "ahuotiyuwiq"; - // constructing and testing `myNonClass` - const myNonClass: NonClass = new NonClass(COLOR_SCHEME_DARK); - myNonClass.name = myName; - myNonClass.timeslots = myTimeslots; - myNonClass.room = myRoom; - expect(myNonClass.events).toStrictEqual([ - new Event(myNonClass, myName, myTimeslots, myRoom), + // constructing and testing `myCustomActivity` + const myCustomActivity: CustomActivity = new CustomActivity( + COLOR_SCHEME_DARK, + ); + myCustomActivity.name = myName; + myCustomActivity.timeslots = myTimeslots; + myCustomActivity.room = myRoom; + expect(myCustomActivity.events).toStrictEqual([ + new Event(myCustomActivity, myName, myTimeslots, myRoom), ]); }); - describe("NonClass.addTimeslot", () => { + describe("CustomActivity.addTimeslot", () => { /** Partition: * - slot matches existing timeslot, doesn't add * - slot extends over multiple days, doesn't add * - slot is valid, adds */ test("adds valid slot", () => { - const myNonClass: NonClass = new NonClass(COLOR_SCHEME_LIGHT); + const myCustomActivity: CustomActivity = new CustomActivity( + COLOR_SCHEME_LIGHT, + ); const myTimeslot: Timeslot = new Timeslot(1, 1); - myNonClass.addTimeslot(myTimeslot); - expect(myNonClass.timeslots).toStrictEqual([myTimeslot]); + myCustomActivity.addTimeslot(myTimeslot); + expect(myCustomActivity.timeslots).toStrictEqual([myTimeslot]); }); test("doesn't add existing slot", () => { - const myNonClass: NonClass = new NonClass(COLOR_SCHEME_LIGHT); + const myCustomActivity: CustomActivity = new CustomActivity( + COLOR_SCHEME_LIGHT, + ); const myTimeslot: Timeslot = new Timeslot(4, 6); - myNonClass.timeslots = [myTimeslot]; - myNonClass.addTimeslot(myTimeslot); - expect(myNonClass.timeslots).toStrictEqual([myTimeslot]); + myCustomActivity.timeslots = [myTimeslot]; + myCustomActivity.addTimeslot(myTimeslot); + expect(myCustomActivity.timeslots).toStrictEqual([myTimeslot]); }); test("doesn't add multi-day slot", () => { - const myNonClass: NonClass = new NonClass(COLOR_SCHEME_LIGHT); - myNonClass.addTimeslot(new Timeslot(42, 69)); - expect(myNonClass.timeslots).toStrictEqual([]); + const myCustomActivity: CustomActivity = new CustomActivity( + COLOR_SCHEME_LIGHT, + ); + myCustomActivity.addTimeslot(new Timeslot(42, 69)); + expect(myCustomActivity.timeslots).toStrictEqual([]); }); }); - describe("NonClass.removeTimeslot", () => { + describe("CustomActivity.removeTimeslot", () => { /** * Partition: - * - NonClass.timeslots (before call): empty, nonempty with match, nonempty without match + * - CustomActivity.timeslots (before call): empty, nonempty with match, nonempty without match */ - test("removing timeslot from empty NonClass", () => { - const myNonClass: NonClass = new NonClass(COLOR_SCHEME_LIGHT); - myNonClass.removeTimeslot(new Timeslot(1, 1)); - expect(myNonClass.timeslots).toStrictEqual([]); + test("removing timeslot from empty CustomActivity", () => { + const myCustomActivity: CustomActivity = new CustomActivity( + COLOR_SCHEME_LIGHT, + ); + myCustomActivity.removeTimeslot(new Timeslot(1, 1)); + expect(myCustomActivity.timeslots).toStrictEqual([]); }); - test("remove matching timeslot from nonempty NonClass", () => { - const myNonClass: NonClass = new NonClass(COLOR_SCHEME_LIGHT); + test("remove matching timeslot from nonempty CustomActivity", () => { + const myCustomActivity: CustomActivity = new CustomActivity( + COLOR_SCHEME_LIGHT, + ); const myTimeslots: Timeslot[] = [ new Timeslot(1, 2), new Timeslot(4, 3), new Timeslot(42, 4), ]; - myNonClass.timeslots = myTimeslots; - myNonClass.removeTimeslot(new Timeslot(1, 2)); - expect(myNonClass.timeslots).toStrictEqual([ + myCustomActivity.timeslots = myTimeslots; + myCustomActivity.removeTimeslot(new Timeslot(1, 2)); + expect(myCustomActivity.timeslots).toStrictEqual([ new Timeslot(4, 3), new Timeslot(42, 4), ]); }); - test("remove non-matching timeslot from nonempty NonClass", () => { - const myNonClass: NonClass = new NonClass(COLOR_SCHEME_LIGHT); + test("remove non-matching timeslot from nonempty CustomActivity", () => { + const myCustomActivity: CustomActivity = new CustomActivity( + COLOR_SCHEME_LIGHT, + ); const myTimeslots: Timeslot[] = [ new Timeslot(1, 2), new Timeslot(4, 3), new Timeslot(42, 4), ]; - myNonClass.timeslots = myTimeslots; - myNonClass.removeTimeslot(new Timeslot(1, 1)); - expect(myNonClass.timeslots).toStrictEqual(myTimeslots); + myCustomActivity.timeslots = myTimeslots; + myCustomActivity.removeTimeslot(new Timeslot(1, 1)); + expect(myCustomActivity.timeslots).toStrictEqual(myTimeslots); }); }); - describe("NonClass.deflate", () => { + describe("CustomActivity.deflate", () => { /** Partition: * - this.timeslots: empty, nonempty * - this.room: defined, undefined */ test("timeslots empty, room undefined", () => { - expect(new NonClass(COLOR_SCHEME_LIGHT).deflate()).toStrictEqual([ + expect(new CustomActivity(COLOR_SCHEME_LIGHT).deflate()).toStrictEqual([ [], "New Activity", "#4A5568", @@ -320,9 +352,11 @@ describe("NonClass", () => { }); test("timeslots empty, room defined", () => { - const myNonClass: NonClass = new NonClass(COLOR_SCHEME_LIGHT); - myNonClass.room = "lorem ipsum"; - expect(myNonClass.deflate()).toStrictEqual([ + const myCustomActivity: CustomActivity = new CustomActivity( + COLOR_SCHEME_LIGHT, + ); + myCustomActivity.room = "lorem ipsum"; + expect(myCustomActivity.deflate()).toStrictEqual([ [], "New Activity", "#4A5568", @@ -331,9 +365,11 @@ describe("NonClass", () => { }); test("timeslots nonempty, room undefined", () => { - const myNonClass: NonClass = new NonClass(COLOR_SCHEME_LIGHT); - myNonClass.timeslots = [new Timeslot(10, 2)]; - expect(myNonClass.deflate()).toStrictEqual([ + const myCustomActivity: CustomActivity = new CustomActivity( + COLOR_SCHEME_LIGHT, + ); + myCustomActivity.timeslots = [new Timeslot(10, 2)]; + expect(myCustomActivity.deflate()).toStrictEqual([ [[10, 2]], "New Activity", "#4A5568", @@ -342,23 +378,27 @@ describe("NonClass", () => { }); }); - describe("NonClass.inflate", () => { + describe("CustomActivity.inflate", () => { /** * Partition on first item: empty, nonempty */ test("first item empty", () => { - const myNonClass: NonClass = new NonClass(COLOR_SCHEME_LIGHT); - myNonClass.inflate([[], "alpha", "#123456", "beta"]); - - expect(myNonClass.timeslots).toStrictEqual([]); - expect(myNonClass.name).toBe("alpha"); - expect(myNonClass.backgroundColor).toBe("#123456"); - expect(myNonClass.room).toBe("beta"); + const myCustomActivity: CustomActivity = new CustomActivity( + COLOR_SCHEME_LIGHT, + ); + myCustomActivity.inflate([[], "alpha", "#123456", "beta"]); + + expect(myCustomActivity.timeslots).toStrictEqual([]); + expect(myCustomActivity.name).toBe("alpha"); + expect(myCustomActivity.backgroundColor).toBe("#123456"); + expect(myCustomActivity.room).toBe("beta"); }); test("first item nonempty", () => { - const myNonClass: NonClass = new NonClass(COLOR_SCHEME_LIGHT); - myNonClass.inflate([ + const myCustomActivity: CustomActivity = new CustomActivity( + COLOR_SCHEME_LIGHT, + ); + myCustomActivity.inflate([ [ [1, 2], [4, 5], @@ -368,13 +408,13 @@ describe("NonClass", () => { "delta", ]); - expect(myNonClass.timeslots).toStrictEqual([ + expect(myCustomActivity.timeslots).toStrictEqual([ new Timeslot(1, 2), new Timeslot(4, 5), ]); - expect(myNonClass.name).toBe("gamma"); - expect(myNonClass.backgroundColor).toBe("#7890AB"); - expect(myNonClass.room).toBe("delta"); + expect(myCustomActivity.name).toBe("gamma"); + expect(myCustomActivity.backgroundColor).toBe("#7890AB"); + expect(myCustomActivity.room).toBe("delta"); }); }); }); diff --git a/tests/class.test.ts b/tests/class.test.ts index 95839f5e..13aaf52b 100644 --- a/tests/class.test.ts +++ b/tests/class.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "vitest"; -import { type Flags, getFlagImg, Class, Sections } from "../src/lib/class"; +import { type Flags, getFlagImg, Class, ClassSections } from "../src/lib/class"; import { CI, GIR, @@ -8,7 +8,7 @@ import { SectionKind, TermCode, type RawClass, -} from "../src/lib/rawClass"; +} from "../src/lib/raw"; import { COLOR_SCHEME_LIGHT } from "../src/lib/colors"; // auxiliary object for testing getFlagImg; change as needed @@ -584,8 +584,8 @@ describe("Class", () => { test("has unlocked sections", () => { const myClass: Class = new Class(myRawClass, COLOR_SCHEME_LIGHT); - const mySections: Sections | undefined = myClass.sections.at(0); - assert(mySections instanceof Sections); + const mySections: ClassSections | undefined = myClass.sections.at(0); + assert(mySections instanceof ClassSections); mySections.locked = true; const expectedDeflated: Deflated = ["21H.143", [""], -1]; expect(myClass.deflate()).toStrictEqual(expectedDeflated); @@ -595,8 +595,9 @@ describe("Class", () => { COLOR_SCHEME_LIGHT, ); myOtherClass.inflate(expectedDeflated); - const myOtherSections: Sections | undefined = myOtherClass.sections.at(0); - assert(myOtherSections instanceof Sections); + const myOtherSections: ClassSections | undefined = + myOtherClass.sections.at(0); + assert(myOtherSections instanceof ClassSections); // If you don't change this, it is `undefined` (TODO: fix!) myOtherSections.selected = null; expect(myClass).toStrictEqual(myOtherClass); @@ -618,8 +619,8 @@ describe("Class", () => { test("has section room override", () => { const myClass: Class = new Class(myRawClass, COLOR_SCHEME_LIGHT); - const mySections: Sections | undefined = myClass.sections.at(0); - assert(mySections instanceof Sections); + const mySections: ClassSections | undefined = myClass.sections.at(0); + assert(mySections instanceof ClassSections); mySections.roomOverride = "lorem"; const expectedDeflated: Deflated = ["21H.143", ["lorem"]]; expect(myClass.deflate()).toStrictEqual(expectedDeflated);