feat: support summer grades, fix summer course parser (#596)
* feat: support summer grades, fix summer course parser * chore: lint * docs: mention summer terms in Course::number description * feat: Course::getNumberWithoutTerm, strip summer term indicator when displaying grades --------- Co-authored-by: doprz <52579214+doprz@users.noreply.github.com>
This commit is contained in:
@@ -44,7 +44,12 @@ export type Semester = {
|
||||
export class Course {
|
||||
/** Every course has a uniqueId within UT's registrar system corresponding to each course section */
|
||||
uniqueId!: number;
|
||||
/** This is the course number for a course, i.e CS 314 would be 314, MAL 306H would be 306H */
|
||||
/**
|
||||
* This is the course number for a course, i.e CS 314 would be 314, MAL 306H would be 306H.
|
||||
* UT prefixes summer courses with f, s, n, or w:
|
||||
* [f]irst term, [s]econd term, [n]ine week term, [w]hole term.
|
||||
* So, the first term of PSY 301 over the summer would be 'f301'
|
||||
*/
|
||||
number!: string;
|
||||
/** The full name of the course, i.e. CS 314 Data Structures and Algorithms */
|
||||
fullName!: string;
|
||||
@@ -91,6 +96,46 @@ export class Course {
|
||||
}
|
||||
this.colors = course.colors ? structuredClone(course.colors) : getCourseColors('emerald', 500);
|
||||
this.core = course.core ?? [];
|
||||
if (course.semester.season === 'Summer') {
|
||||
// A bug from and old version put the summer term in the course,
|
||||
// so we need to handle that case
|
||||
const { department, number } = Course.cleanSummerTerm(course.department, course.number);
|
||||
this.department = department;
|
||||
this.number = number;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Due to a bug in an older version, the summer term was included in the course department code,
|
||||
* instead of the course number.
|
||||
* UT prefixes summer courses with f, s, n, or w:
|
||||
* [f]irst term, [s]econd term, [n]ine week term, [w]hole term
|
||||
*
|
||||
* @param department - The course department code, like 'C S'
|
||||
* @param number - The course number, like '314H'
|
||||
* @returns The properly formatted department and course number
|
||||
* @example
|
||||
* ```ts
|
||||
* cleanSummerTerm('C S', '314H') // { department: 'C S', number: '314H' }
|
||||
* cleanSummerTerm('P R', 'f378') // { department: 'P R', number: 'f378' }
|
||||
* cleanSummerTerm('P R f', '378') // { department: 'P R', number: 'f378' }
|
||||
* cleanSummerTerm('P S', 'n303') // { department: 'P S', number: 'n303' }
|
||||
* cleanSummerTerm('P S n', '303') // { department: 'P S', number: 'n303' }
|
||||
* ```
|
||||
*/
|
||||
static cleanSummerTerm(department: string, number: string): { department: string; number: string } {
|
||||
// UT prefixes summer courses with f, s, n, or w:
|
||||
// [f]irst term, [s]econd term, [n]ine week term, [w]hole term
|
||||
const summerTerm = department.match(/[fsnw]$/);
|
||||
|
||||
if (!summerTerm) {
|
||||
return { department, number };
|
||||
}
|
||||
|
||||
return {
|
||||
department: department.slice(0, -1).trim(),
|
||||
number: summerTerm[0] + number,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,6 +156,18 @@ export class Course {
|
||||
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns The course number without the summer term
|
||||
* @example
|
||||
* ```ts
|
||||
* const c = new Course({ number: 'f301', ... });
|
||||
* c.getNumberWithoutTerm() // '301'
|
||||
* ```
|
||||
*/
|
||||
getNumberWithoutTerm(): string {
|
||||
return this.number.replace(/^\D/, ''); // Remove nondigit at start, if it exists
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
57
src/shared/types/tests/Course.test.ts
Normal file
57
src/shared/types/tests/Course.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { Course } from '../Course';
|
||||
|
||||
describe('Course::cleanSummerTerm', () => {
|
||||
it("shouldn't affect already cleaned summer terms", () => {
|
||||
const inputs = [
|
||||
['C S', '314H'],
|
||||
['P R', 'f378'],
|
||||
['P S', 'f303'],
|
||||
['WGS', 's301'],
|
||||
['S W', 'n360K'],
|
||||
['GOV', 'w312L'],
|
||||
['J', 's311F'],
|
||||
['J S', '311F'],
|
||||
] as const;
|
||||
const expected = [
|
||||
{ department: 'C S', number: '314H' },
|
||||
{ department: 'P R', number: 'f378' },
|
||||
{ department: 'P S', number: 'f303' },
|
||||
{ department: 'WGS', number: 's301' },
|
||||
{ department: 'S W', number: 'n360K' },
|
||||
{ department: 'GOV', number: 'w312L' },
|
||||
{ department: 'J', number: 's311F' },
|
||||
{ department: 'J S', number: '311F' },
|
||||
];
|
||||
|
||||
const results = inputs.map(input => Course.cleanSummerTerm(input[0], input[1]));
|
||||
|
||||
expect(results).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should move summer term indicator to course number', () => {
|
||||
const inputs = [
|
||||
['P R f', '378'],
|
||||
['P S f', '303'],
|
||||
['WGS s', '301'],
|
||||
['S W n', '360K'],
|
||||
['GOV w', '312L'],
|
||||
['J s', '311F'],
|
||||
['J S', '311F'],
|
||||
] as const;
|
||||
const expected = [
|
||||
{ department: 'P R', number: 'f378' },
|
||||
{ department: 'P S', number: 'f303' },
|
||||
{ department: 'WGS', number: 's301' },
|
||||
{ department: 'S W', number: 'n360K' },
|
||||
{ department: 'GOV', number: 'w312L' },
|
||||
{ department: 'J', number: 's311F' },
|
||||
{ department: 'J S', number: '311F' },
|
||||
];
|
||||
|
||||
const results = inputs.map(input => Course.cleanSummerTerm(input[0], input[1]));
|
||||
|
||||
expect(results).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -73,7 +73,7 @@ const generateCourses = (count: number): Course[] => {
|
||||
|
||||
const exampleCourses = generateCourses(numberOfCourses);
|
||||
|
||||
type CourseWithId = Course & BaseItem;
|
||||
type CourseWithId = { course: Course } & BaseItem;
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Common/SortableList',
|
||||
@@ -91,11 +91,10 @@ export const Default: Story = {
|
||||
args: {
|
||||
draggables: exampleCourses.map(course => ({
|
||||
id: course.uniqueId,
|
||||
...course,
|
||||
getConflicts: course.getConflicts,
|
||||
course,
|
||||
})),
|
||||
onChange: () => {},
|
||||
renderItem: course => <PopupCourseBlock key={course.id} course={course} colors={course.colors} />,
|
||||
renderItem: ({ id, course }) => <PopupCourseBlock key={id} course={course} colors={course.colors} />,
|
||||
},
|
||||
render: args => (
|
||||
<div className='h-3xl w-3xl transform-none'>
|
||||
|
||||
@@ -155,15 +155,14 @@ export default function PopupMain(): JSX.Element {
|
||||
<SortableList
|
||||
draggables={activeSchedule.courses.map(course => ({
|
||||
id: course.uniqueId,
|
||||
...course,
|
||||
getConflicts: course.getConflicts,
|
||||
course,
|
||||
}))}
|
||||
onChange={reordered => {
|
||||
activeSchedule.courses = reordered.map(({ id: _id, ...course }) => course);
|
||||
activeSchedule.courses = reordered.map(({ course }) => course);
|
||||
replaceSchedule(getActiveSchedule(), activeSchedule);
|
||||
}}
|
||||
renderItem={course => (
|
||||
<PopupCourseBlock key={course.id} course={course} colors={course.colors} />
|
||||
renderItem={({ id, course }) => (
|
||||
<PopupCourseBlock key={id} course={course} colors={course.colors} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -215,7 +215,7 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
|
||||
options={{
|
||||
...chartOptions,
|
||||
title: {
|
||||
text: `There is currently no grade distribution data for ${course.department} ${course.number}`,
|
||||
text: `There is currently no grade distribution data for ${course.department} ${course.getNumberWithoutTerm()}`,
|
||||
},
|
||||
tooltip: { enabled: false },
|
||||
}}
|
||||
@@ -228,7 +228,7 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
|
||||
<Text variant='small' className='text-ut-black'>
|
||||
Grade Distribution for{' '}
|
||||
<Text variant='small' className='font-extrabold!' as='strong'>
|
||||
{course.department} {course.number}
|
||||
{course.department} {course.getNumberWithoutTerm()}
|
||||
</Text>
|
||||
</Text>
|
||||
<select
|
||||
@@ -267,7 +267,8 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
|
||||
<div className='mt-3 flex flex-wrap content-center items-center self-stretch justify-center gap-3 text-center'>
|
||||
<Text variant='small' className='text-theme-red'>
|
||||
We couldn't find {semester !== 'Aggregate' && ` ${semester}`} grades for this
|
||||
instructor, so here are the grades for all {course.department} {course.number} sections.
|
||||
instructor, so here are the grades for all {course.department}{' '}
|
||||
{course.getNumberWithoutTerm()} sections.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
34
src/views/lib/CourseCatalogScraper.test.ts
Normal file
34
src/views/lib/CourseCatalogScraper.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { CourseCatalogScraper } from './CourseCatalogScraper';
|
||||
|
||||
describe('CourseCatalogScraper::separateCourseName', () => {
|
||||
it('should separate a simple course', () => {
|
||||
// UT Formats strings weird... lots of meaningless spaces
|
||||
const input = 'C S 314H DATA STRUCTURES: HONORS ';
|
||||
const expected = ['DATA STRUCTURES: HONORS', 'C S', '314H'];
|
||||
const result = CourseCatalogScraper.separateCourseName(input);
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('separate summer courses ', () => {
|
||||
// UT Formats strings weird... lots of meaningless spaces
|
||||
const inputs = [
|
||||
'P R f378 PUBLIC RELATNS TECHNIQUES-IRL (First term) ',
|
||||
'CRP s396 INDEPENDENT RESEARCH IN CRP (Second term) ',
|
||||
'B A n284S 1-MANAGERIAL MICROECON-I-DAL (Nine week term) ',
|
||||
'J w379 JOURNALISM INDEPENDENT STUDY (Whole term) ',
|
||||
];
|
||||
|
||||
const expected = [
|
||||
['PUBLIC RELATNS TECHNIQUES-IRL (First term)', 'P R', 'f378'],
|
||||
['INDEPENDENT RESEARCH IN CRP (Second term)', 'CRP', 's396'],
|
||||
['1-MANAGERIAL MICROECON-I-DAL (Nine week term)', 'B A', 'n284S'],
|
||||
['JOURNALISM INDEPENDENT STUDY (Whole term)', 'J', 'w379'],
|
||||
];
|
||||
const results = inputs.map(input => CourseCatalogScraper.separateCourseName(input));
|
||||
|
||||
expect(results).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -75,7 +75,7 @@ export class CourseCatalogScraper {
|
||||
|
||||
fullName = fullName.replace(/\s\s+/g, ' ').trim();
|
||||
|
||||
const [courseName, department, number] = this.separateCourseName(fullName);
|
||||
const [courseName, department, number] = CourseCatalogScraper.separateCourseName(fullName);
|
||||
const [status, isReserved] = this.getStatus(row);
|
||||
|
||||
const newCourse = new Course({
|
||||
@@ -113,16 +113,31 @@ export class CourseCatalogScraper {
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
* separateCourseName("CS 314H - Honors Discrete Structures") => ["Honors Discrete Structures", "CS", "314H"]
|
||||
* separateCourseName("C S 314H DATA STRUCTURES: HONORS") => ["DATA STRUCTURES: HONORS", "C S", "314H"]
|
||||
* ```
|
||||
* @param courseFullName - the full name of the course (e.g. "CS 314H - Honors Discrete Structures")
|
||||
* @returns an array of the course name , department, and number
|
||||
* @param courseFullName - the full name of the course (e.g. "C S 314H DATA STRUCTURES: HONORS")
|
||||
* @returns an array of the course name, department, and number
|
||||
*/
|
||||
separateCourseName(courseFullName: string): [courseName: string, department: string, number: string] {
|
||||
let courseNumberIndex = courseFullName.search(/\d/);
|
||||
let department = courseFullName.substring(0, courseNumberIndex).trim();
|
||||
let number = courseFullName.substring(courseNumberIndex, courseFullName.indexOf(' ', courseNumberIndex)).trim();
|
||||
let courseName = courseFullName.substring(courseFullName.indexOf(' ', courseNumberIndex)).trim();
|
||||
static separateCourseName(courseFullName: string): [courseName: string, department: string, number: string] {
|
||||
// C S 314H DATA STRUCTURES: HONORS
|
||||
// ^ Here for normal courses
|
||||
// B A n284S 1-MANAGERIAL MICROECON-I-DAL (Nine week term)
|
||||
// ^ Also works for summer courses ([f]irst term, [s]econd term, [n]ine week term, [w]hole term)
|
||||
const courseNumberIndex = courseFullName.search(/\w?\d/);
|
||||
|
||||
if (courseNumberIndex === -1) {
|
||||
throw new Error("Course name doesn't have a course number");
|
||||
}
|
||||
|
||||
// Everything before the course number
|
||||
const department = courseFullName.substring(0, courseNumberIndex).trim();
|
||||
|
||||
const number = courseFullName
|
||||
.substring(courseNumberIndex, courseFullName.indexOf(' ', courseNumberIndex))
|
||||
.trim();
|
||||
|
||||
// Everything after the course number
|
||||
const courseName = courseFullName.substring(courseFullName.indexOf(' ', courseNumberIndex)).trim();
|
||||
|
||||
return [courseName, department, number];
|
||||
}
|
||||
|
||||
@@ -109,16 +109,22 @@ function generateQuery(
|
||||
includeInstructor: boolean
|
||||
): [string, GradeDistributionParams] {
|
||||
const query = `
|
||||
select * from grade_distributions
|
||||
where Department_Code = :department_code
|
||||
and Course_Number = :course_number
|
||||
${includeInstructor ? `and Instructor_Last = :instructor_last collate nocase` : ''}
|
||||
${semester ? `and Semester = :semester` : ''}
|
||||
SELECT * FROM grade_distributions
|
||||
WHERE Department_Code = :department_code
|
||||
AND Course_Number COLLATE NOCASE IN (
|
||||
:course_number,
|
||||
concat('F', :course_number), -- Check summer courses with prefix, too
|
||||
concat('S', :course_number),
|
||||
concat('N', :course_number),
|
||||
concat('W', :course_number)
|
||||
)
|
||||
${includeInstructor ? `AND Instructor_Last = :instructor_last COLLATE NOCASE` : ''}
|
||||
${semester ? `AND Semester = :semester` : ''}
|
||||
`;
|
||||
|
||||
const params: GradeDistributionParams = {
|
||||
':department_code': course.department,
|
||||
':course_number': course.number,
|
||||
':course_number': course.getNumberWithoutTerm(),
|
||||
};
|
||||
|
||||
if (includeInstructor) {
|
||||
|
||||
Reference in New Issue
Block a user