Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Show latest term if term indicated by URL is invalid #112

Merged
merged 10 commits into from
Jan 8, 2025
62 changes: 46 additions & 16 deletions src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -55,23 +60,48 @@ function useHydrant(): {
};

useEffect(() => {
const params = new URLSearchParams(document.location.search);
const term = params.get("t") ?? "latest";
Promise.all([
fetchNoCache<LatestTermInfo>("latestTerm.json"),
fetchNoCache<SemesterData>(`${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<LatestTermInfo>("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<SemesterData>(`${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();
Expand Down
35 changes: 34 additions & 1 deletion src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 (
<Flex align="center" gap={3} wrap="wrap">
<Flex direction="column" gap={1}>
Expand All @@ -143,6 +157,25 @@ export function Header(props: { state: State; preferences: Preferences }) {
</Flex>
</Flex>
<PreferencesDialog preferences={preferences} state={state} />
{show && (
<Card.Root size="sm" variant="subtle">
<Card.Body px={3} py={1}>
<Flex align="center" gap={1.5}>
<Text fontSize="sm">
Term {urlNameOrig} not found; loaded term {urlName} instead.
</Text>
<IconButton
variant="subtle"
size="xs"
aria-label="Close"
onClick={onClose}
>
<LuX />
</IconButton>
</Flex>
</Card.Body>
</Card.Root>
)}
</Flex>
);
}
49 changes: 1 addition & 48 deletions src/components/TermSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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;
Expand Down
104 changes: 104 additions & 0 deletions src/lib/dates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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;
Expand Down
Loading