diff --git a/src/components/App.tsx b/src/components/App.tsx index 7f940f8..f32c86d 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -7,7 +7,12 @@ import { Tooltip } from "./ui/tooltip"; import { Provider } from "./ui/provider"; import { useColorMode } from "./ui/color-mode"; -import { LatestTermInfo, Term, TermInfo } from "../lib/dates"; +import { + LatestTermInfo, + Term, + TermInfo, + getClosestUrlName, +} from "../lib/dates"; import { State } from "../lib/state"; import { RawClass } from "../lib/rawClass"; import { Class } from "../lib/class"; @@ -55,23 +60,48 @@ function useHydrant(): { }; useEffect(() => { - const params = new URLSearchParams(document.location.search); - const term = params.get("t") ?? "latest"; - Promise.all([ - fetchNoCache("latestTerm.json"), - fetchNoCache(`${term}.json`), - ]).then(([latestTerm, { classes, lastUpdated, termInfo }]) => { - const classesMap = new Map(Object.entries(classes)); - const hydrantObj = new State( - classesMap, - new Term(termInfo), - lastUpdated, + const fetchData = async () => { + const latestTerm = await fetchNoCache("latestTerm.json"); + const params = new URLSearchParams(document.location.search); + + const urlNameOrig = params.get("t"); + const { urlName, shouldWarn } = getClosestUrlName( + urlNameOrig, latestTerm.semester.urlName, ); - hydrantRef.current = hydrantObj; - setLoading(false); - window.hydrant = hydrantObj; - }); + + if (urlName === urlNameOrig || urlNameOrig === null) { + const term = + urlName === latestTerm.semester.urlName ? "latest" : urlName; + const { classes, lastUpdated, termInfo } = + await fetchNoCache(`${term}.json`); + const classesMap = new Map(Object.entries(classes)); + const hydrantObj = new State( + classesMap, + new Term(termInfo), + lastUpdated, + latestTerm.semester.urlName, + ); + hydrantRef.current = hydrantObj; + setLoading(false); + window.hydrant = hydrantObj; + } else { + // Redirect to the indicated term, while storing the initially requested + // term in the "ti" parameter (if necessary) so that the user can be + // notified + if (urlName === latestTerm.semester.urlName) { + params.delete("t"); + } else { + params.set("t", urlName); + } + if (shouldWarn) { + params.set("ti", urlNameOrig!); + } + window.location.search = params.toString(); + } + }; + + fetchData(); }, []); const { colorMode, toggleColorMode } = useColorMode(); diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 1a7694b..e23d558 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,4 +1,5 @@ -import { Flex, Image } from "@chakra-ui/react"; +import { Card, IconButton, Flex, Image, Text } from "@chakra-ui/react"; +import { LuX } from "react-icons/lu"; import { DialogRoot, @@ -128,6 +129,19 @@ export function Header(props: { state: State; preferences: Preferences }) { const { state, preferences } = props; const logoSrc = useColorModeValue(logo, logoDark); + const params = new URLSearchParams(document.location.search); + const urlNameOrig = params.get("ti"); + const urlName = params.get("t") ?? state.latestUrlName; + + const [show, setShow] = useState(urlNameOrig !== null); + + const onClose = () => { + const url = new URL(window.location.href); + url.searchParams.delete("ti"); + window.history.pushState({}, "", url); + setShow(false); + }; + return ( @@ -143,6 +157,25 @@ export function Header(props: { state: State; preferences: Preferences }) { + {show && ( + + + + + Term {urlNameOrig} not found; loaded term {urlName} instead. + + + + + + + + )} ); } diff --git a/src/components/TermSwitcher.tsx b/src/components/TermSwitcher.tsx index 3add3eb..3eede48 100644 --- a/src/components/TermSwitcher.tsx +++ b/src/components/TermSwitcher.tsx @@ -10,54 +10,7 @@ import { import { createListCollection } from "@chakra-ui/react"; import { State } from "../lib/state"; -import { Term } from "../lib/dates"; - -/** Given a urlName like i22, return its corresponding URL. */ -function toFullUrl(urlName: string, latestUrlName: string): string { - const url = new URL(window.location.href); - Array.from(url.searchParams.keys()).forEach((key) => { - url.searchParams.delete(key); - }); - if (urlName !== latestUrlName) { - url.searchParams.set("t", urlName); - } - return url.href; -} - -/** Given a urlName like "i22", return the previous one, "f21". */ -function getLastUrlName(urlName: string): string { - const { semester, year } = new Term({ urlName }); - switch (semester) { - case "f": - return `m${year}`; - case "m": - return `s${year}`; - case "s": - return `i${year}`; - case "i": - return `f${parseInt(year, 10) - 1}`; - } -} - -/** urlNames that don't have a State */ -const EXCLUDED_URLS = ["i23", "m23", "i24", "m24"]; - -/** Earliest urlName we have a State for. */ -const EARLIEST_URL = "f22"; - -/** Return all urlNames before the given one. */ -function getUrlNames(latestUrlName: string): Array { - let urlName = latestUrlName; - const res = []; - while (urlName !== EARLIEST_URL) { - res.push(urlName); - do { - urlName = getLastUrlName(urlName); - } while (EXCLUDED_URLS.includes(urlName)); - } - res.push(EARLIEST_URL); - return res; -} +import { Term, toFullUrl, getUrlNames } from "../lib/dates"; export function TermSwitcher(props: { state: State }) { const { state } = props; diff --git a/src/lib/dates.ts b/src/lib/dates.ts index 06c652f..e365efe 100644 --- a/src/lib/dates.ts +++ b/src/lib/dates.ts @@ -146,6 +146,110 @@ export function parseUrlName(urlName: string): { }; } +/** Given a urlName like i22, return its corresponding URL. */ +export function toFullUrl(urlName: string, latestUrlName: string): string { + const url = new URL(window.location.href); + Array.from(url.searchParams.keys()).forEach((key) => { + url.searchParams.delete(key); + }); + if (urlName !== latestUrlName) { + url.searchParams.set("t", urlName); + } + return url.href; +} + +/** Given a urlName like "i22", return the previous one, "f21". */ +function getLastUrlName(urlName: string): string { + const { semester, year } = new Term({ urlName }); + switch (semester) { + case "f": + return `m${year}`; + case "m": + return `s${year}`; + case "s": + return `i${year}`; + case "i": + return `f${parseInt(year, 10) - 1}`; + } +} + +/** Given a urlName like "i22", return the next one, "s22". */ +function getNextUrlName(urlName: string): string { + const { semester, year } = new Term({ urlName }); + switch (semester) { + case "i": + return `s${year}`; + case "s": + return `m${year}`; + case "m": + return `f${year}`; + case "f": + return `i${parseInt(year, 10) + 1}`; + } +} + +/** urlNames that don't have a State */ +const EXCLUDED_URLS = ["i23", "m23", "i24", "m24"]; + +/** Earliest urlName we have a State for. */ +const EARLIEST_URL = "f22"; + +/** Return all urlNames before the given one. */ +export function getUrlNames(latestUrlName: string): Array { + let urlName = latestUrlName; + const res = []; + while (urlName !== EARLIEST_URL) { + res.push(urlName); + do { + urlName = getLastUrlName(urlName); + } while (EXCLUDED_URLS.includes(urlName)); + } + res.push(EARLIEST_URL); + return res; +} + +/** + * Return the "closest" urlName to the one provided, as well as whether or not + * the user should be shown a warning that this does not match the term + * requested. + */ +export function getClosestUrlName( + urlName: string | null, + latestUrlName: string, +): { + urlName: string; + shouldWarn: boolean; +} { + if (urlName === null || urlName === "" || urlName === "latest") { + return { urlName: latestUrlName, shouldWarn: false }; + } + + const urlNames = getUrlNames(latestUrlName); + if (urlNames.includes(urlName)) { + return { urlName: urlName, shouldWarn: false }; + } + + // IAP or summer for a year where those were folded into spring/fall + if (EXCLUDED_URLS.includes(urlName)) { + const nextUrlName = getNextUrlName(urlName); + if (urlNames.includes(nextUrlName)) { + // modified: false because in these cases, e.g. s24 includes the data + // corresponding to i24 + return { urlName: nextUrlName, shouldWarn: false }; + } + } + + const urlNamesSameSem = urlNames.filter((u) => u[0] === urlName[0]); + if (urlNamesSameSem.length > 0) { + // Unrecognized term, but we can return the latest term of the same type of + // semester (fall, spring, etc.) + return { urlName: urlNamesSameSem[0], shouldWarn: true }; + } + + // Fallback: return latest term + return { urlName: latestUrlName, shouldWarn: true }; +} + /** Type of object passed to Term constructor. */ export type TermInfo = { urlName: string;