diff --git a/web/src/lib/term-format.test.ts b/web/src/lib/term-format.test.ts new file mode 100644 index 0000000..0a64f8e --- /dev/null +++ b/web/src/lib/term-format.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from "vitest"; +import { termToFriendly, termToBanner } from "./term-format"; + +describe("termToFriendly", () => { + it("converts spring term correctly", () => { + expect(termToFriendly("202610")).toBe("spring-26"); + expect(termToFriendly("202510")).toBe("spring-25"); + }); + + it("converts summer term correctly", () => { + expect(termToFriendly("202620")).toBe("summer-26"); + expect(termToFriendly("202520")).toBe("summer-25"); + }); + + it("converts fall term correctly", () => { + expect(termToFriendly("202630")).toBe("fall-26"); + expect(termToFriendly("202530")).toBe("fall-25"); + }); + + it("returns null for invalid codes", () => { + expect(termToFriendly("20261")).toBe(null); + expect(termToFriendly("2026100")).toBe(null); + expect(termToFriendly("202640")).toBe(null); // Invalid semester code + expect(termToFriendly("")).toBe(null); + }); +}); + +describe("termToBanner", () => { + it("converts spring term correctly", () => { + expect(termToBanner("spring-26")).toBe("202610"); + expect(termToBanner("spring-25")).toBe("202510"); + }); + + it("converts summer term correctly", () => { + expect(termToBanner("summer-26")).toBe("202620"); + expect(termToBanner("summer-25")).toBe("202520"); + }); + + it("converts fall term correctly", () => { + expect(termToBanner("fall-26")).toBe("202630"); + expect(termToBanner("fall-25")).toBe("202530"); + }); + + it("returns null for invalid formats", () => { + expect(termToBanner("winter-26")).toBe(null); + expect(termToBanner("spring26")).toBe(null); + expect(termToBanner("spring-2026")).toBe(null); + expect(termToBanner("26-spring")).toBe(null); + expect(termToBanner("")).toBe(null); + }); +}); + +describe("round-trip conversion", () => { + it("converts back and forth correctly", () => { + const bannerCodes = ["202610", "202620", "202630", "202510"]; + + for (const code of bannerCodes) { + const friendly = termToFriendly(code); + expect(friendly).not.toBeNull(); + const backToBanner = termToBanner(friendly!); + expect(backToBanner).toBe(code); + } + }); +}); diff --git a/web/src/lib/term-format.ts b/web/src/lib/term-format.ts new file mode 100644 index 0000000..ae31e7d --- /dev/null +++ b/web/src/lib/term-format.ts @@ -0,0 +1,48 @@ +/** + * Convert between Banner's internal term codes (e.g., "202620") and human-friendly format (e.g., "summer-26") + */ + +export type SemesterName = "spring" | "summer" | "fall"; + +const SEMESTER_CODES: Record = { + "10": "spring", + "20": "summer", + "30": "fall", +}; + +const SEMESTER_TO_CODE: Record = { + spring: "10", + summer: "20", + fall: "30", +}; + +/** + * Convert Banner term code (e.g., "202620") to friendly format (e.g., "summer-26") + */ +export function termToFriendly(bannerCode: string): string | null { + if (bannerCode.length !== 6) return null; + + const year = bannerCode.substring(0, 4); + const semesterCode = bannerCode.substring(4, 6); + const semester = SEMESTER_CODES[semesterCode]; + + if (!semester) return null; + + const shortYear = year.substring(2, 4); + return `${semester}-${shortYear}`; +} + +/** + * Convert friendly format (e.g., "summer-26") to Banner term code (e.g., "202620") + */ +export function termToBanner(friendly: string): string | null { + const match = friendly.match(/^(spring|summer|fall)-(\d{2})$/); + if (!match) return null; + + const [, semester, shortYear] = match; + const semesterCode = SEMESTER_TO_CODE[semester as SemesterName]; + if (!semesterCode) return null; + + const fullYear = `20${shortYear}`; + return `${fullYear}${semesterCode}`; +} diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 82e5669..c41ea6a 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -14,14 +14,17 @@ import SearchStatus, { type SearchMeta } from "$lib/components/SearchStatus.svel import CourseTable from "$lib/components/CourseTable.svelte"; import Pagination from "$lib/components/Pagination.svelte"; import Footer from "$lib/components/Footer.svelte"; +import { termToBanner, termToFriendly } from "$lib/term-format"; let { data } = $props(); // Read initial state from URL params (intentionally captured once) const initialParams = untrack(() => new URLSearchParams(data.url.search)); -// Filter state -let selectedTerm = $state(untrack(() => initialParams.get("term") ?? data.terms[0]?.code ?? "")); +// Filter state - only set term from URL if present (no auto-default) +const urlTerm = initialParams.get("term"); +const bannerTerm = urlTerm ? (termToBanner(urlTerm) ?? "") : ""; +let selectedTerm = $state(bannerTerm); let selectedSubjects: string[] = $state(untrack(() => initialParams.getAll("subject"))); let query = $state(initialParams.get("q") ?? ""); let openOnly = $state(initialParams.get("open") === "true"); @@ -160,7 +163,10 @@ async function performSearch( sort.length > 0 ? (sort[0].desc ? "desc" : "asc") : undefined; const params = new URLSearchParams(); - params.set("term", term); + const friendlyTerm = termToFriendly(term); + if (friendlyTerm) { + params.set("term", friendlyTerm); + } for (const s of subjects) { params.append("subject", s); }