diff --git a/client/src/components/course-schedule.tsx b/client/src/components/course-schedule.tsx index e6282612..ee59edab 100644 --- a/client/src/components/course-schedule.tsx +++ b/client/src/components/course-schedule.tsx @@ -6,13 +6,15 @@ import { twMerge } from 'tailwind-merge'; import * as buildingCodes from '../assets/building-codes.json'; import * as buildingCoordinates from '../assets/building-coordinates.json'; import { type IcsEventOptions, sanitizeForFilename } from '../lib/calendar'; -import type { Block, Schedule, TimeBlock } from '../lib/types'; +import type { Block, Schedule, Season, TimeBlock } from '../lib/types'; import type { Course } from '../lib/types'; import { formatDisplayTime, + formatTerm, getCurrentTerm, groupBy, mapValues, + parseTerm, sortBy, sortTerms, uniq, @@ -36,11 +38,14 @@ const DAY_ORDER = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']; const DEFAULT_MEETING_COUNT = 13; -const TERM_START_CONFIG = { +const TERM_START_CONFIG: Record< + Season, + { startMonth: number; offsetDays: number } +> = { Winter: { startMonth: 1, offsetDays: 6 }, Summer: { startMonth: 5, offsetDays: 6 }, Fall: { startMonth: 9, offsetDays: 6 }, -} as const; +}; type ScheduleBlock = Omit & { location: string; @@ -54,8 +59,6 @@ type RepeatingBlock = { endTime: string; }; -type TermSeason = keyof typeof TERM_START_CONFIG; - const VSBtimeToDisplay = (time: string) => { const totalMinutes = parseInt(time, 10); @@ -71,18 +74,6 @@ const VSBtimeToDisplay = (time: string) => { .padStart(2, '0')}`; }; -const parseTermSeason = ( - term: string -): { season: TermSeason; year: number } | null => { - const match = term.match(/^(Winter|Summer|Fall)\s+(\d{4})$/); - - if (!match) return null; - - const [, season, year] = match; - - return { season: season as TermSeason, year: parseInt(year, 10) }; -}; - const vsbDayToJsDay = (day: string): number | null => { const parsed = parseInt(day, 10); @@ -99,10 +90,10 @@ const getFirstOccurrenceForTermDay = ( term: string, day: string ): Date | null => { - const termInfo = parseTermSeason(term); + const termInfo = parseTerm(term); const jsDay = vsbDayToJsDay(day); - if (!termInfo || jsDay === null) return null; + if (jsDay === null || Number.isNaN(termInfo.year)) return null; const { season, year } = termInfo; const { startMonth, offsetDays } = TERM_START_CONFIG[season]; @@ -452,8 +443,10 @@ const ScheduleRow = ({ block, course, term }: ScheduleRowProps) => { }; const getDefaultTerm = (offeredTerms: string[]) => { - const currentTerm = getCurrentTerm(); - return offeredTerms.includes(currentTerm) ? currentTerm : offeredTerms.at(0); + const currentTermStr = formatTerm(getCurrentTerm()); + return offeredTerms.includes(currentTermStr) + ? currentTermStr + : offeredTerms.at(0); }; type CourseScheduleProps = { diff --git a/client/src/components/course-terms.tsx b/client/src/components/course-terms.tsx index 5b8d0aec..2c03cdf3 100644 --- a/client/src/components/course-terms.tsx +++ b/client/src/components/course-terms.tsx @@ -7,8 +7,10 @@ import { twMerge } from 'tailwind-merge'; import type { Course } from '../lib/types'; import { compareTerms, + formatTerm, getCurrentTerms, groupCurrentCourseTermInstructors, + parseTerm, } from '../lib/utils'; import { Highlight } from './highlight'; import { Tooltip } from './tooltip'; @@ -25,7 +27,7 @@ type SeasonIconProps = { const SeasonIcon = ({ variant, term }: SeasonIconProps) => { const size = variantToSize(variant); - const season = term.split(' ')[0].toLowerCase(); + const season = parseTerm(term).season.toLowerCase(); const icons: Record = { fall: , @@ -74,8 +76,9 @@ export const CourseTerms = ({ course, variant, query }: CourseTermsProps) => { setExpandedState(initialExpandedState()); }, [course]); + const currentTermStrings = getCurrentTerms().map(formatTerm); const currentlyOfferedTerms = course.terms.filter((c) => - getCurrentTerms().includes(c) + currentTermStrings.includes(c) ); if (currentlyOfferedTerms.length === 0) @@ -100,7 +103,7 @@ export const CourseTerms = ({ course, variant, query }: CourseTermsProps) => { {Object.entries(instructorGroups) .sort((a, b) => compareTerms(a[0], b[0])) .map(([term, instructors], i) => { - const season = term.split(' ')[0].toLowerCase(); + const season = parseTerm(term).season.toLowerCase(); return (
{ - const currentTerm = getCurrentTerm(); + const currentTermStr = formatTerm(getCurrentTerm()); - if (finalExams.term !== currentTerm) { + if (finalExams.term !== currentTermStr) { return null; } @@ -148,7 +148,7 @@ export const FinalExamRow = ({ course, className }: FinalExamRowProps) => { Final Exam

- {currentTerm} + {currentTermStr}

@@ -180,9 +180,9 @@ export const FinalExamRow = ({ course, className }: FinalExamRowProps) => { .filter(Boolean) .join('\n'); - const filename = `${sanitizeForFilename(`${examId}-final-exam-${currentTerm}`)}.ics`; + const filename = `${sanitizeForFilename(`${examId}-final-exam-${currentTermStr}`)}.ics`; - const uid = `${sanitizeForFilename(`${course._id}-${exam.startTime}-${exam.endTime}-${currentTerm}`).slice(0, 64)}@mcgill.courses`; + const uid = `${sanitizeForFilename(`${course._id}-${exam.startTime}-${exam.endTime}-${currentTermStr}`).slice(0, 64)}@mcgill.courses`; const calendarPayload = { filename, diff --git a/client/src/components/gpa-chart.tsx b/client/src/components/gpa-chart.tsx index 7a3061c4..da84c8e8 100644 --- a/client/src/components/gpa-chart.tsx +++ b/client/src/components/gpa-chart.tsx @@ -11,7 +11,7 @@ import { } from 'recharts'; import type { CourseAverage, Grade, Instructor } from '../lib/types'; -import { compareTerms } from '../lib/utils'; +import { compareTerms, parseTerm } from '../lib/utils'; const gradeToGPA: Record = { A: 4.0, @@ -39,11 +39,9 @@ type DataPoint = { instructors: string[]; }; -const formatShortTerm = (term: string): string => { - const [season, year] = term.split(' '); - const seasonAbbrev = season[0]; - const yearAbbrev = year?.slice(-2) ?? ''; - return `${seasonAbbrev}${yearAbbrev}`; +const formatShortTerm = (termStr: string): string => { + const { season, year } = parseTerm(termStr); + return `${season[0]}${String(year).slice(-2)}`; }; const calculateTrendLine = ( diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index 86719011..ee8f5259 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -2,6 +2,13 @@ Generated by typeshare 1.13.3 */ +export type Season = 'Winter' | 'Summer' | 'Fall'; + +export interface Term { + season: Season; + year: number; +} + export interface AddOrUpdateReviewBody { /** The review content/text. */ content: string; diff --git a/client/src/lib/utils.test.ts b/client/src/lib/utils.test.ts index f42c2bb2..7ed35d81 100644 --- a/client/src/lib/utils.test.ts +++ b/client/src/lib/utils.test.ts @@ -23,6 +23,7 @@ import { spliceCourseCode, stripColonPrefix, sum, + term, timeSince, uniq, uniqBy, @@ -146,9 +147,9 @@ describe('getCurrentTerms', () => { vi.setSystemTime(new Date('2024-06-15')); expect(getCurrentTerms()).toEqual([ - 'Summer 2024', - 'Fall 2024', - 'Winter 2025', + term('Summer', 2024), + term('Fall', 2024), + term('Winter', 2025), ]); }); @@ -156,9 +157,9 @@ describe('getCurrentTerms', () => { vi.setSystemTime(new Date('2024-09-15')); expect(getCurrentTerms()).toEqual([ - 'Fall 2024', - 'Winter 2025', - 'Summer 2025', + term('Fall', 2024), + term('Winter', 2025), + term('Summer', 2025), ]); }); @@ -166,9 +167,9 @@ describe('getCurrentTerms', () => { vi.setSystemTime(new Date('2024-03-15')); expect(getCurrentTerms()).toEqual([ - 'Fall 2023', - 'Winter 2024', - 'Summer 2024', + term('Fall', 2023), + term('Winter', 2024), + term('Summer', 2024), ]); }); @@ -176,25 +177,25 @@ describe('getCurrentTerms', () => { vi.setSystemTime(new Date('2024-05-01T12:00:00Z')); expect(getCurrentTerms()).toEqual([ - 'Summer 2024', - 'Fall 2024', - 'Winter 2025', + term('Summer', 2024), + term('Fall', 2024), + term('Winter', 2025), ]); vi.setSystemTime(new Date('2024-08-01T12:00:00Z')); expect(getCurrentTerms()).toEqual([ - 'Fall 2024', - 'Winter 2025', - 'Summer 2025', + term('Fall', 2024), + term('Winter', 2025), + term('Summer', 2025), ]); vi.setSystemTime(new Date('2024-01-01T12:00:00Z')); expect(getCurrentTerms()).toEqual([ - 'Fall 2023', - 'Winter 2024', - 'Summer 2024', + term('Fall', 2023), + term('Winter', 2024), + term('Summer', 2024), ]); }); }); diff --git a/client/src/lib/utils.ts b/client/src/lib/utils.ts index 6a76273f..a23c1ec4 100644 --- a/client/src/lib/utils.ts +++ b/client/src/lib/utils.ts @@ -1,4 +1,11 @@ -import type { Course, Instructor, Review, Schedule } from './types'; +import type { + Course, + Instructor, + Review, + Schedule, + Season, + Term, +} from './types'; /** * Regex pattern for validating McGill course codes. @@ -12,7 +19,31 @@ const COURSE_CODE_REGEX = /^(([A-Z0-9]){4} [0-9]{3}(D1|D2|N1|N2|J1|J2|J3)?)$/; /** * Order of terms within an academic year for sorting purposes. */ -const TERM_ORDER = ['Winter', 'Summer', 'Fall']; +const TERM_ORDER: Season[] = ['Winter', 'Summer', 'Fall']; + +/** + * Creates a Term object from a season and year. + */ +export const term = (season: Season, year: number): Term => ({ season, year }); + +/** + * Parses a term string like "Fall 2025" into a Term object. + * + * @param {string} s - Term string to parse + * @returns {Term} Parsed term + */ +export const parseTerm = (s: string): Term => { + const [season, yearStr] = s.split(' '); + return { season: season as Season, year: parseInt(yearStr, 10) }; +}; + +/** + * Formats a Term object into a string like "Fall 2025". + * + * @param {Term} t - Term to format + * @returns {string} Formatted term string + */ +export const formatTerm = (t: Term): string => `${t.season} ${t.year}`; /** * Custom error class for date-related errors. @@ -39,15 +70,19 @@ export const capitalize = (s: string): string => * Compares two academic terms for sorting. * * Terms are compared first by year, then by season according to TERM_ORDER. + * Accepts either Term objects or term strings. * - * @param {string} a - First term string (e.g., "Fall 2023") - * @param {string} b - Second term string (e.g., "Winter 2024") + * @param {string | Term} a - First term + * @param {string | Term} b - Second term * @returns {number} Negative if a comes before b, positive if b comes before a, 0 if equal */ -export const compareTerms = (a: string, b: string): number => { - return a.split(' ')[1] === b.split(' ')[1] - ? TERM_ORDER.indexOf(a.split(' ')[0]) - TERM_ORDER.indexOf(b.split(' ')[0]) - : parseInt(a.split(' ')[1], 10) - parseInt(b.split(' ')[1], 10); +export const compareTerms = (a: string | Term, b: string | Term): number => { + const pa = typeof a === 'string' ? parseTerm(a) : a; + const pb = typeof b === 'string' ? parseTerm(b) : b; + + return pa.year === pb.year + ? TERM_ORDER.indexOf(pa.season) - TERM_ORDER.indexOf(pb.season) + : pa.year - pb.year; }; /** @@ -111,22 +146,22 @@ export const formatDisplayTime = (time: string): string => { * - August-December: Fall * - January-April: Winter * - * @returns {string} The current term + * @returns {Term} The current term */ -export const getCurrentTerm = (): string => { +export const getCurrentTerm = (): Term => { const now = new Date(); const month = now.getMonth() + 1; const year = now.getFullYear(); if (month >= 5 && month < 8) { - return `Summer ${year}`; + return term('Summer', year); } if (month >= 8) { - return `Fall ${year}`; + return term('Fall', year); } - return `Winter ${year}`; + return term('Winter', year); }; /** @@ -136,23 +171,27 @@ export const getCurrentTerm = (): string => { * - August-December: Returns [Fall current, Winter next, Summer next] * - January-April: Returns [Fall previous, Winter current, Summer current] * - * @returns {[string, string, string]} Array of three consecutive terms + * @returns {[Term, Term, Term]} Array of three consecutive terms */ -export const getCurrentTerms = (): [string, string, string] => { +export const getCurrentTerms = (): [Term, Term, Term] => { const now = new Date(); const month = now.getMonth() + 1; const year = now.getFullYear(); if (month >= 5 && month < 8) { - return [`Summer ${year}`, `Fall ${year}`, `Winter ${year + 1}`]; + return [term('Summer', year), term('Fall', year), term('Winter', year + 1)]; } if (month >= 8) { - return [`Fall ${year}`, `Winter ${year + 1}`, `Summer ${year + 1}`]; + return [ + term('Fall', year), + term('Winter', year + 1), + term('Summer', year + 1), + ]; } - return [`Fall ${year - 1}`, `Winter ${year}`, `Summer ${year}`]; + return [term('Fall', year - 1), term('Winter', year), term('Summer', year)]; }; /** @@ -198,17 +237,17 @@ export const groupBy = ( export const groupCurrentCourseTermInstructors = ( course: Course ): Record => { - const currentTerms = getCurrentTerms(); + const currentTermStrings = getCurrentTerms().map(formatTerm); const currentInstructors = course.instructors.filter((i) => - currentTerms.includes(i.term) + currentTermStrings.includes(i.term) ); const termGroups = groupBy(currentInstructors, (i: Instructor) => i.term); - for (const term of course.terms) { - if (term in termGroups || !currentTerms.includes(term)) continue; - termGroups[term] = []; + for (const t of course.terms) { + if (t in termGroups || !currentTermStrings.includes(t)) continue; + termGroups[t] = []; } return termGroups; diff --git a/client/src/pages/course-page.tsx b/client/src/pages/course-page.tsx index 9173257a..cf8fda84 100644 --- a/client/src/pages/course-page.tsx +++ b/client/src/pages/course-page.tsx @@ -19,11 +19,11 @@ import { ReviewFilter, ReviewSortType } from '../components/review-filter'; import { useAuth } from '../hooks/use-auth'; import { api } from '../lib/api'; import type { Course, CourseAverage, Interaction, Review } from '../lib/types'; -import { getCurrentTerms, getReviewAnchorId } from '../lib/utils'; +import { formatTerm, getCurrentTerms, getReviewAnchorId } from '../lib/utils'; import { Loading } from './loading'; export const CoursePage = () => { - const currentTerms = getCurrentTerms(); + const currentTermStrings = getCurrentTerms().map(formatTerm); const firstFetch = useRef(true); const hasAttemptedScroll = useRef(false); const highlightTimeoutRef = useRef(null); @@ -169,10 +169,10 @@ export const CoursePage = () => { return ; } - if (course.terms.some((term) => !currentTerms.includes(term))) { + if (course.terms.some((term) => !currentTermStrings.includes(term))) { setCourse({ ...course, - terms: course.terms.filter((term) => currentTerms.includes(term)), + terms: course.terms.filter((term) => currentTermStrings.includes(term)), }); } diff --git a/client/src/pages/explore.tsx b/client/src/pages/explore.tsx index 7b915bbf..896eebfe 100644 --- a/client/src/pages/explore.tsx +++ b/client/src/pages/explore.tsx @@ -16,7 +16,7 @@ import { useExploreFilterState } from '../hooks/use-explore-filter-state'; import { api } from '../lib/api'; import type { Course, CourseFilter } from '../lib/types'; import { CourseSortType } from '../lib/types'; -import { getCurrentTerms } from '../lib/utils'; +import { formatTerm, getCurrentTerms } from '../lib/utils'; const COURSE_LIMIT = 20; @@ -28,7 +28,7 @@ export const Explore = () => { const [query, setQuery] = useState(''); const [searchSelected, setSearchSelected] = useState(false); - const currentTerms = getCurrentTerms(); + const currentTerms = getCurrentTerms().map(formatTerm); const debouncedQuery = useDebouncedValue(query, 250); const { selectedSubjects, selectedLevels, selectedTerms, sortBy } = diff --git a/client/src/pages/instructor.tsx b/client/src/pages/instructor.tsx index 3db435b9..5e04502b 100644 --- a/client/src/pages/instructor.tsx +++ b/client/src/pages/instructor.tsx @@ -14,8 +14,10 @@ import { Course, type Instructor as InstructorType } from '../lib/types'; import type { Review } from '../lib/types'; import { courseIdToUrlParam, + formatTerm, getCurrentTerm, getCurrentTerms, + parseTerm, } from '../lib/utils'; import { Loading } from './loading'; import { NotFound } from './not-found'; @@ -33,7 +35,8 @@ export const Instructor = () => { const [courses, setCourses] = useState([]); const currentTerm = getCurrentTerm(); - const [activeTab, setActiveTab] = useState(currentTerm); + const currentTermStr = formatTerm(currentTerm); + const [activeTab, setActiveTab] = useState(currentTermStr); const user = useAuth(); @@ -52,12 +55,12 @@ export const Instructor = () => { }); }, [params.name]); - const academicTerms = getCurrentTerms(); + const academicTerms = getCurrentTerms().map(formatTerm); // Reorder terms so current term is first const orderedTerms = [ - currentTerm, - ...academicTerms.filter((t) => t !== currentTerm), + currentTermStr, + ...academicTerms.filter((t) => t !== currentTermStr), ]; const getCoursesForTerm = (term: string) => { @@ -69,12 +72,12 @@ export const Instructor = () => { ); }; - const currentTermHasCourses = getCoursesForTerm(currentTerm).length > 0; + const currentTermHasCourses = getCoursesForTerm(currentTermStr).length > 0; useEffect(() => { if (instructor) { if (currentTermHasCourses) { - setActiveTab(currentTerm); + setActiveTab(currentTermStr); } else { setActiveTab('all'); } @@ -164,7 +167,7 @@ export const Instructor = () => { const termCourses = getCoursesForTerm(term); if (termCourses.length === 0) return null; - const season = term.split(' ')[0].toLowerCase(); + const season = parseTerm(term).season.toLowerCase(); const icon = season === 'fall' ? ( diff --git a/crates/db/src/db.rs b/crates/db/src/db.rs index fe169fba..9d60728c 100644 --- a/crates/db/src/db.rs +++ b/crates/db/src/db.rs @@ -89,6 +89,12 @@ impl Db { if let Some(query) = query.clone() { let current_terms = current_terms(); + let current_terms_pattern = current_terms + .iter() + .map(ToString::to_string) + .collect::>() + .join("|"); + let id = doc! { "_id": doc! { "$regex": format!(".*{}.*", query.replace(' ', "")), @@ -104,7 +110,7 @@ impl Db { "$options": "i" }, "term": doc! { - "$regex": format!(".*({}).*", current_terms.join("|")), + "$regex": format!(".*({}).*", current_terms_pattern), "$options": "i" } } @@ -225,18 +231,20 @@ impl Db { } pub async fn add_course_average(&self, average: CourseAverage) -> Result { + let term_str = average.term.to_string(); + self .database .collection::(Self::COURSE_AVERAGE_COLLECTION) .update_one( doc! { "courseId": &average.course_id, - "term": &average.term, + "term": &term_str, }, doc! { "$setOnInsert": { "courseId": &average.course_id, - "term": &average.term, + "term": &term_str, "average": average.average.to_string(), } }, @@ -2174,7 +2182,7 @@ mod tests { course .terms .iter() - .any(|term| term.starts_with(&"Winter".to_string())) + .any(|term| term.to_string().starts_with("Winter")) ); } } @@ -2186,17 +2194,17 @@ mod tests { let instructors = vec![ Instructor { name: "foo".into(), - term: "Summer 2023".into(), + term: "Summer 2023".parse().unwrap(), ..Default::default() }, Instructor { name: "bar".into(), - term: "Summer 2023".into(), + term: "Summer 2023".parse().unwrap(), ..Default::default() }, Instructor { name: "bar".into(), - term: "Winter 2023".into(), + term: "Winter 2023".parse().unwrap(), ..Default::default() }, ]; @@ -2752,7 +2760,7 @@ mod tests { instructors: vec![Instructor { name: "Lili Wei".into(), name_ngrams: None, - term: "Fall 2024".into(), + term: "Fall 2024".parse().unwrap(), }], ..Default::default() }) @@ -2765,7 +2773,7 @@ mod tests { instructors: vec![Instructor { name: "Giulia Alberini".into(), name_ngrams: None, - term: "Fall 2024".into(), + term: "Fall 2024".parse().unwrap(), }], ..Default::default() }) diff --git a/crates/db/src/lib.rs b/crates/db/src/lib.rs index db1a51de..17a3584b 100644 --- a/crates/db/src/lib.rs +++ b/crates/db/src/lib.rs @@ -7,7 +7,7 @@ use { model::{ Course, CourseAverage, CourseFilter, CourseSortType, InitializeOptions, Instructor, Interaction, InteractionKind, Notification, Review, - ReviewFilter, SearchResults, Subscription, + ReviewFilter, SearchResults, Season, Subscription, Term, }, mongodb::{ Client, Cursor, Database, IndexModel, diff --git a/crates/db/src/utils.rs b/crates/db/src/utils.rs index d76f6041..a7bfb635 100644 --- a/crates/db/src/utils.rs +++ b/crates/db/src/utils.rs @@ -1,21 +1,21 @@ use super::*; -pub(crate) fn current_terms() -> Vec { +pub(crate) fn current_terms() -> Vec { let now = Utc::now().date_naive(); - let (month, year) = (now.month(), now.year()); + let (month, year) = (now.month(), now.year() as u16); if month >= 8 { return vec![ - format!("Fall {year}"), - format!("Winter {}", year + 1), - format!("Summer {}", year + 1), + Term::new(Season::Fall, year), + Term::new(Season::Winter, year + 1), + Term::new(Season::Summer, year + 1), ]; } vec![ - format!("Fall {}", year - 1), - format!("Winter {year}"), - format!("Summer {year}"), + Term::new(Season::Fall, year - 1), + Term::new(Season::Winter, year), + Term::new(Season::Summer, year), ] } diff --git a/crates/model/src/course.rs b/crates/model/src/course.rs index 8faf7a86..fac730f3 100644 --- a/crates/model/src/course.rs +++ b/crates/model/src/course.rs @@ -29,7 +29,7 @@ pub struct Course { /// Faculty offering the course. pub faculty: String, /// Terms when the course is offered. - pub terms: Vec, + pub terms: Vec, /// Course description. pub description: String, /// Instructors associated with the course. @@ -128,13 +128,13 @@ mod tests { Instructor { name: name.to_string(), name_ngrams: None, - term: term.to_string(), + term: term.parse().unwrap(), } } fn schedule(term: &str) -> Schedule { Schedule { - term: Some(term.to_string()), + term: Some(term.parse().unwrap()), blocks: None, } } @@ -328,12 +328,12 @@ mod tests { #[test] fn merge_terms_combines_unique() { let course1 = Course { - terms: vec!["Fall 2023".to_string(), "Winter 2024".to_string()], + terms: vec!["Fall 2023".parse().unwrap(), "Winter 2024".parse().unwrap()], ..course() }; let course2 = Course { - terms: vec!["Winter 2024".to_string(), "Fall 2024".to_string()], + terms: vec!["Winter 2024".parse().unwrap(), "Fall 2024".parse().unwrap()], ..course() }; @@ -342,9 +342,9 @@ mod tests { assert_eq!( merged.terms, vec![ - "Fall 2023".to_string(), - "Winter 2024".to_string(), - "Fall 2024".to_string(), + "Fall 2023".parse::().unwrap(), + "Winter 2024".parse::().unwrap(), + "Fall 2024".parse::().unwrap(), ] ); } diff --git a/crates/model/src/course_average.rs b/crates/model/src/course_average.rs index 736c7a42..f97bb61a 100644 --- a/crates/model/src/course_average.rs +++ b/crates/model/src/course_average.rs @@ -9,7 +9,7 @@ pub struct CourseAverage { /// Course identifier (e.g., "COMP202"). pub course_id: String, /// Term name (e.g., "Fall 2024", "Winter 2025"). - pub term: String, + pub term: Term, /// Letter grade average. pub average: Grade, } diff --git a/crates/model/src/course_page.rs b/crates/model/src/course_page.rs index 15748128..bd3b0cf1 100644 --- a/crates/model/src/course_page.rs +++ b/crates/model/src/course_page.rs @@ -6,7 +6,7 @@ pub struct CoursePage { pub credits: String, pub subject: String, pub code: String, - pub terms: Vec, + pub terms: Vec, pub description: String, pub department: Option, pub faculty: Option, diff --git a/crates/model/src/instructor.rs b/crates/model/src/instructor.rs index f4c7f2e1..1e4ee9f8 100644 --- a/crates/model/src/instructor.rs +++ b/crates/model/src/instructor.rs @@ -21,7 +21,7 @@ pub struct Instructor { /// Search n-grams for the instructor name. pub name_ngrams: Option, /// Term identifier for the instructor record. - pub term: String, + pub term: Term, } impl Into for Instructor { @@ -46,10 +46,7 @@ impl Instructor { } } - pub fn set_term(self, term: &str) -> Self { - Self { - term: term.to_owned(), - ..self - } + pub fn set_term(self, term: Term) -> Self { + Self { term, ..self } } } diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 849851e7..43444fcc 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -12,6 +12,7 @@ use { collections::HashSet, fmt::{self, Display, Formatter}, path::PathBuf, + str::FromStr, }, typeshare::typeshare, utoipa::{ @@ -40,6 +41,7 @@ mod review_filter; mod schedule; mod search_results; mod subscription; +mod term; pub use crate::{ course::Course, @@ -59,4 +61,5 @@ pub use crate::{ schedule::{Block, Schedule, TimeBlock}, search_results::SearchResults, subscription::Subscription, + term::{Season, Term}, }; diff --git a/crates/model/src/schedule.rs b/crates/model/src/schedule.rs index adb67aa6..c37de936 100644 --- a/crates/model/src/schedule.rs +++ b/crates/model/src/schedule.rs @@ -96,7 +96,7 @@ pub struct Schedule { /// Schedule blocks for the term. pub blocks: Option>, /// Term identifier for the schedule. - pub term: Option, + pub term: Option, } impl Into for Schedule { diff --git a/crates/model/src/term.rs b/crates/model/src/term.rs new file mode 100644 index 00000000..c08190d4 --- /dev/null +++ b/crates/model/src/term.rs @@ -0,0 +1,177 @@ +use super::*; + +#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum Season { + Winter, + Summer, + #[default] + Fall, +} + +impl Display for Season { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Season::Fall => write!(f, "Fall"), + Season::Winter => write!(f, "Winter"), + Season::Summer => write!(f, "Summer"), + } + } +} + +impl FromStr for Season { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + "Fall" | "fall" => Ok(Season::Fall), + "Winter" | "winter" => Ok(Season::Winter), + "Summer" | "summer" => Ok(Season::Summer), + _ => Err(format!("invalid season: {value}")), + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[typeshare(serialized_as = "String")] +pub struct Term { + pub year: u16, + pub season: Season, +} + +impl Display for Term { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{} {}", self.season, self.year) + } +} + +impl FromStr for Term { + type Err = String; + + fn from_str(value: &str) -> Result { + let (season_str, year_str) = value + .split_once(' ') + .ok_or_else(|| format!("invalid term format: {value}"))?; + + let season = season_str.parse()?; + + let year = year_str + .parse() + .map_err(|_| format!("invalid year in term: {value}"))?; + + Ok(Term { season, year }) + } +} + +impl Serialize for Term { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for Term { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + String::deserialize(deserializer)? + .parse() + .map_err(D::Error::custom) + } +} + +impl From for Bson { + fn from(term: Term) -> Self { + Bson::String(term.to_string()) + } +} + +impl ToSchema for Term { + fn name() -> Cow<'static, str> { + Cow::Borrowed("Term") + } +} + +impl PartialSchema for Term { + fn schema() -> RefOr { + Object::builder() + .schema_type(Type::String) + .description(Some("Academic term (e.g. 'Fall 2025')")) + .examples(["Fall 2025", "Winter 2026", "Summer 2025"]) + .into() + } +} + +impl Term { + pub fn new(season: Season, year: u16) -> Self { + Self { season, year } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_valid_term() { + assert_eq!( + "Fall 2025".parse::().unwrap(), + Term::new(Season::Fall, 2025) + ) + } + + #[test] + fn parse_all_seasons() { + assert_eq!( + "Winter 2024".parse::().unwrap(), + Term::new(Season::Winter, 2024) + ); + + assert_eq!( + "Summer 2024".parse::().unwrap(), + Term::new(Season::Summer, 2024) + ); + + assert_eq!( + "Fall 2024".parse::().unwrap(), + Term::new(Season::Fall, 2024) + ); + } + + #[test] + fn display_term() { + assert_eq!(Term::new(Season::Fall, 2025).to_string(), "Fall 2025"); + } + + #[test] + fn roundtrip_serde() { + let term = Term::new(Season::Winter, 2026); + + let json = serde_json::to_string(&term).unwrap(); + assert_eq!(json, "\"Winter 2026\""); + + assert_eq!(serde_json::from_str::(&json).unwrap(), term); + } + + #[test] + fn parse_invalid_term() { + assert!("InvalidTerm".parse::().is_err()); + assert!("Fall".parse::().is_err()); + assert!("2025".parse::().is_err()); + assert!("Spring 2025".parse::().is_err()); + } + + #[test] + fn ordering() { + let fall_2024 = Term::new(Season::Fall, 2024); + let winter_2025 = Term::new(Season::Winter, 2025); + let summer_2025 = Term::new(Season::Summer, 2025); + let fall_2025 = Term::new(Season::Fall, 2025); + + assert!(fall_2024 < winter_2025); + assert!(winter_2025 < summer_2025); + assert!(summer_2025 < fall_2025); + } +} diff --git a/src/server.rs b/src/server.rs index 7de9bbef..ab427712 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1640,7 +1640,7 @@ mod tests { name_ngrams: Some( "Adr Adri Adria Adrian Ros Rosh Rosha Roshan Vet Vett Vetta".into() ), - term: "Fall 2022".into(), + term: "Fall 2022".parse().unwrap(), }) ); @@ -2576,7 +2576,7 @@ mod tests { db.add_course_average(model::CourseAverage { course_id: "COMP202".into(), - term: "Fall 2024".into(), + term: "Fall 2024".parse().unwrap(), average: Grade::BPlus, }) .await @@ -2584,7 +2584,7 @@ mod tests { db.add_course_average(model::CourseAverage { course_id: "COMP202".into(), - term: "Winter 2024".into(), + term: "Winter 2024".parse().unwrap(), average: Grade::B, }) .await @@ -2592,7 +2592,7 @@ mod tests { db.add_course_average(model::CourseAverage { course_id: "MATH240".into(), - term: "Fall 2024".into(), + term: "Fall 2024".parse().unwrap(), average: Grade::AMinus, }) .await @@ -2620,7 +2620,7 @@ mod tests { db.add_course_average(model::CourseAverage { course_id: "COMP202".into(), - term: "Fall 2024".into(), + term: "Fall 2024".parse().unwrap(), average: Grade::BPlus, }) .await @@ -2628,7 +2628,7 @@ mod tests { db.add_course_average(model::CourseAverage { course_id: "MATH240".into(), - term: "Fall 2024".into(), + term: "Fall 2024".parse().unwrap(), average: Grade::AMinus, }) .await diff --git a/tools/scraper/src/course_extractor.rs b/tools/scraper/src/course_extractor.rs index 74309191..832c5064 100644 --- a/tools/scraper/src/course_extractor.rs +++ b/tools/scraper/src/course_extractor.rs @@ -57,7 +57,7 @@ pub fn extract_course_page(text: &str) -> Result { elem .inner_html() .split(",") - .map(|term| term.trim().to_owned()) + .filter_map(|term| term.trim().parse::().ok()) .collect::>() }) .unwrap_or_default(); @@ -266,7 +266,10 @@ mod tests { credits: "0".into(), subject: "AAAA".into(), code: "100".into(), - terms: vec!["Fall 2025".into(), "Winter 2026".into()], + terms: vec![ + "Fall 2025".parse().unwrap(), + "Winter 2026".parse().unwrap() + ], department: Some("Student Services".into()), faculty: Some("No College Designated".into()), description: "".into(), diff --git a/tools/scraper/src/loader.rs b/tools/scraper/src/loader.rs index b98486f8..bf1f7b51 100644 --- a/tools/scraper/src/loader.rs +++ b/tools/scraper/src/loader.rs @@ -319,7 +319,7 @@ impl Loader { let schedule_info = schedule.clone().map(|schedules| { let mut terms = schedules .iter() - .filter_map(|schedule| schedule.term.clone()) + .filter_map(|schedule| schedule.term) .collect::>(); utils::dedup(&mut terms); @@ -334,12 +334,17 @@ impl Loader { "[{}] Extracted instructor: '{}' (term: {})", course_id, instructor, - schedule.term.as_deref().unwrap_or("unknown") + schedule + .term + .as_ref() + .map(ToString::to_string) + .as_deref() + .unwrap_or("unknown") ); instructors.push(Instructor { name: instructor, - term: schedule.term.clone().unwrap_or_default(), + term: schedule.term.unwrap_or_default(), ..Default::default() }); } diff --git a/tools/scraper/src/main.rs b/tools/scraper/src/main.rs index 2576768d..489ad2bd 100644 --- a/tools/scraper/src/main.rs +++ b/tools/scraper/src/main.rs @@ -5,7 +5,7 @@ use { clap::Parser, model::{ Block, Course, CoursePage, Instructor, Requirement, Requirements, Schedule, - TimeBlock, + Term, TimeBlock, }, rayon::iter::{IntoParallelRefIterator, ParallelIterator}, regex::Regex, diff --git a/tools/scraper/src/vsb_extractor.rs b/tools/scraper/src/vsb_extractor.rs index f0309073..90d347e8 100644 --- a/tools/scraper/src/vsb_extractor.rs +++ b/tools/scraper/src/vsb_extractor.rs @@ -13,13 +13,13 @@ pub(crate) fn extract_course_schedules(text: &str) -> Result> { .root_element() .select_single("term")? .attr("v") - .map(String::from); + .and_then(|v| v.parse::().ok()); html .root_element() .select_many("uselection")? .into_iter() - .map(|elem| extract_course_schedule(elem, term.clone())) + .map(|elem| extract_course_schedule(elem, term)) .collect::>>()? } else { Vec::new() @@ -28,7 +28,7 @@ pub(crate) fn extract_course_schedules(text: &str) -> Result> { fn extract_course_schedule( element: ElementRef, - term: Option, + term: Option, ) -> Result { let timeblocks = element.select_many("timeblock")?; @@ -125,7 +125,7 @@ mod tests { crn: Some("2411".into()), instructors: vec!["Mona Elsaadawy".into(), "Jacob Errington".into()] }]), - term: Some("Fall 2025".into()) + term: Some("Fall 2025".parse().unwrap()) }] ); }