Compare commits
6 Commits
v2.2.1
...
derek/coll
| Author | SHA1 | Date | |
|---|---|---|---|
| a423c6ed4e | |||
|
|
7b401add15 | ||
|
|
2d92dd47f0 | ||
|
|
eb8141ee8c | ||
|
|
2a50f5580d | ||
|
|
65bfb1d129 |
@@ -7,3 +7,6 @@ insert_final_newline = true
|
|||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
indent_size = 4
|
indent_size = 4
|
||||||
indent_style = space
|
indent_style = space
|
||||||
|
|
||||||
|
[*.nix]
|
||||||
|
indent_size = 2
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -211,3 +211,5 @@ sketch
|
|||||||
package-lock.json
|
package-lock.json
|
||||||
storybook-static/
|
storybook-static/
|
||||||
package/
|
package/
|
||||||
|
|
||||||
|
.direnv/
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -26,8 +26,9 @@
|
|||||||
## Toolchain
|
## Toolchain
|
||||||
|
|
||||||
- React v20.9.0 (LTS)
|
- React v20.9.0 (LTS)
|
||||||
- TypeScript
|
- TypeScript v5.x
|
||||||
- Vite 5
|
- Vite v5.x
|
||||||
|
- pnpm v10.x
|
||||||
- UnoCSS
|
- UnoCSS
|
||||||
- ESLint
|
- ESLint
|
||||||
- Prettier
|
- Prettier
|
||||||
@@ -184,8 +185,9 @@ We maintain a strict code of conduct. By contributing, you agree to adhere to th
|
|||||||
Special thanks to the developers and contributors behind these amazing tools and libraries:
|
Special thanks to the developers and contributors behind these amazing tools and libraries:
|
||||||
|
|
||||||
- React v20.9.0 (LTS)
|
- React v20.9.0 (LTS)
|
||||||
- TypeScript
|
- TypeScript v5.x
|
||||||
- Vite 5
|
- Vite v5.x
|
||||||
|
- pnpm v10.x
|
||||||
- UnoCSS
|
- UnoCSS
|
||||||
- ESLint
|
- ESLint
|
||||||
- Prettier
|
- Prettier
|
||||||
|
|||||||
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1744932701,
|
||||||
|
"narHash": "sha256-fusHbZCyv126cyArUwwKrLdCkgVAIaa/fQJYFlCEqiU=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "b024ced1aac25639f8ca8fdfc2f8c4fbd66c48ef",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
32
flake.nix
Normal file
32
flake.nix
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
inputs = {
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
inputs:
|
||||||
|
inputs.flake-utils.lib.eachDefaultSystem (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
pkgs = (import (inputs.nixpkgs) { inherit system; });
|
||||||
|
in
|
||||||
|
{
|
||||||
|
formatter = pkgs.nixfmt-rfc-style;
|
||||||
|
|
||||||
|
devShell = pkgs.mkShell {
|
||||||
|
buildInputs = with pkgs; [
|
||||||
|
nodejs_20 # v20.19.0
|
||||||
|
pnpm_10 # v10.8.1
|
||||||
|
just
|
||||||
|
];
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
echo "UTRP Nix Flake Environment Loaded"
|
||||||
|
echo "Node: $(node --version)"
|
||||||
|
echo "pnpm: $(pnpm --version)"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -37,6 +37,9 @@ export default async function createSchedule(scheduleName: string) {
|
|||||||
|
|
||||||
await UserScheduleStore.set('schedules', schedules);
|
await UserScheduleStore.set('schedules', schedules);
|
||||||
|
|
||||||
|
// Automatically switch to the new schedule
|
||||||
|
await UserScheduleStore.set('activeIndex', schedules.length - 1);
|
||||||
|
|
||||||
// If there is only one schedule, set the active index to the new schedule
|
// If there is only one schedule, set the active index to the new schedule
|
||||||
if (schedules.length <= 1) {
|
if (schedules.length <= 1) {
|
||||||
await UserScheduleStore.set('activeIndex', 0);
|
await UserScheduleStore.set('activeIndex', 0);
|
||||||
|
|||||||
@@ -31,5 +31,9 @@ export default async function duplicateSchedule(scheduleId: string): Promise<str
|
|||||||
} satisfies typeof schedule);
|
} satisfies typeof schedule);
|
||||||
|
|
||||||
await UserScheduleStore.set('schedules', schedules);
|
await UserScheduleStore.set('schedules', schedules);
|
||||||
|
|
||||||
|
// Automatically switch to the duplicated schedule
|
||||||
|
await UserScheduleStore.set('activeIndex', scheduleIndex + 1);
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,12 @@ export type Semester = {
|
|||||||
export class Course {
|
export class Course {
|
||||||
/** Every course has a uniqueId within UT's registrar system corresponding to each course section */
|
/** Every course has a uniqueId within UT's registrar system corresponding to each course section */
|
||||||
uniqueId!: number;
|
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;
|
number!: string;
|
||||||
/** The full name of the course, i.e. CS 314 Data Structures and Algorithms */
|
/** The full name of the course, i.e. CS 314 Data Structures and Algorithms */
|
||||||
fullName!: string;
|
fullName!: string;
|
||||||
@@ -91,6 +96,46 @@ export class Course {
|
|||||||
}
|
}
|
||||||
this.colors = course.colors ? structuredClone(course.colors) : getCourseColors('emerald', 500);
|
this.colors = course.colors ? structuredClone(course.colors) : getCourseColors('emerald', 500);
|
||||||
this.core = course.core ?? [];
|
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;
|
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);
|
const exampleCourses = generateCourses(numberOfCourses);
|
||||||
|
|
||||||
type CourseWithId = Course & BaseItem;
|
type CourseWithId = { course: Course } & BaseItem;
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Components/Common/SortableList',
|
title: 'Components/Common/SortableList',
|
||||||
@@ -91,11 +91,10 @@ export const Default: Story = {
|
|||||||
args: {
|
args: {
|
||||||
draggables: exampleCourses.map(course => ({
|
draggables: exampleCourses.map(course => ({
|
||||||
id: course.uniqueId,
|
id: course.uniqueId,
|
||||||
...course,
|
course,
|
||||||
getConflicts: course.getConflicts,
|
|
||||||
})),
|
})),
|
||||||
onChange: () => {},
|
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 => (
|
render: args => (
|
||||||
<div className='h-3xl w-3xl transform-none'>
|
<div className='h-3xl w-3xl transform-none'>
|
||||||
|
|||||||
@@ -155,15 +155,14 @@ export default function PopupMain(): JSX.Element {
|
|||||||
<SortableList
|
<SortableList
|
||||||
draggables={activeSchedule.courses.map(course => ({
|
draggables={activeSchedule.courses.map(course => ({
|
||||||
id: course.uniqueId,
|
id: course.uniqueId,
|
||||||
...course,
|
course,
|
||||||
getConflicts: course.getConflicts,
|
|
||||||
}))}
|
}))}
|
||||||
onChange={reordered => {
|
onChange={reordered => {
|
||||||
activeSchedule.courses = reordered.map(({ id: _id, ...course }) => course);
|
activeSchedule.courses = reordered.map(({ course }) => course);
|
||||||
replaceSchedule(getActiveSchedule(), activeSchedule);
|
replaceSchedule(getActiveSchedule(), activeSchedule);
|
||||||
}}
|
}}
|
||||||
renderItem={course => (
|
renderItem={({ id, course }) => (
|
||||||
<PopupCourseBlock key={course.id} course={course} colors={course.colors} />
|
<PopupCourseBlock key={id} course={course} colors={course.colors} />
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function ScheduleDropdown({ defaultOpen, children }: ScheduleDrop
|
|||||||
const [activeSchedule] = useSchedules();
|
const [activeSchedule] = useSchedules();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='border border-ut-offwhite/50 rounded bg-white'>
|
<div className='max-h-[200px] flex flex-col border border-ut-offwhite/50 rounded bg-white'>
|
||||||
<Disclosure defaultOpen={defaultOpen}>
|
<Disclosure defaultOpen={defaultOpen}>
|
||||||
{({ open }) => (
|
{({ open }) => (
|
||||||
<>
|
<>
|
||||||
@@ -54,17 +54,17 @@ export default function ScheduleDropdown({ defaultOpen, children }: ScheduleDrop
|
|||||||
|
|
||||||
<Transition
|
<Transition
|
||||||
as='div'
|
as='div'
|
||||||
className='overflow-hidden'
|
className='flex flex-1 flex-col overflow-y-hidden'
|
||||||
enter='transition-[max-height,opacity,padding] duration-300 ease-in-out-expo'
|
enter='transition-[max-height,opacity,padding] duration-300 ease-in-out-expo'
|
||||||
enterFrom='max-h-0 opacity-0 p-0.5'
|
enterFrom='max-h-0 opacity-0 p-0.5'
|
||||||
enterTo='max-h-[440px] opacity-100 p-0'
|
enterTo='max-h-[200px] opacity-100 p-0'
|
||||||
leave='transition-[max-height,opacity,padding] duration-300 ease-in-out-expo'
|
leave='transition-[max-height,opacity,padding] duration-300 ease-in-out-expo'
|
||||||
leaveFrom='max-h-[440px] opacity-100 p-0'
|
leaveFrom='max-h-[200px] opacity-100 p-0'
|
||||||
leaveTo='max-h-0 opacity-0 p-0.5'
|
leaveTo='max-h-0 opacity-0 p-0.5'
|
||||||
>
|
>
|
||||||
<div className='px-3.5 pb-2.5 pt-2'>
|
<DisclosurePanel className='mx-1.75 mb-2.5 mt-2 flex flex-1 flex-col overflow-y-auto'>
|
||||||
<DisclosurePanel>{children}</DisclosurePanel>
|
<div className='mx-1.75'>{children}</div>
|
||||||
</div>
|
</DisclosurePanel>
|
||||||
</Transition>
|
</Transition>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
|
|
||||||
|
function getCourseSections() {
|
||||||
|
const table = document.querySelector('table');
|
||||||
|
if (!table) return [];
|
||||||
|
|
||||||
|
const rows = Array.from(table.querySelectorAll('tr'));
|
||||||
|
const sections: { header: HTMLTableRowElement; children: HTMLTableRowElement[] }[] = [];
|
||||||
|
let currentSection: { header: HTMLTableRowElement; children: HTMLTableRowElement[] } | null = null;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const headerCell = row.querySelector('td.course_header');
|
||||||
|
if (headerCell) {
|
||||||
|
if (currentSection) sections.push(currentSection);
|
||||||
|
currentSection = { header: row, children: [] };
|
||||||
|
} else if (currentSection) {
|
||||||
|
currentSection.children.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentSection) sections.push(currentSection);
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CollapsibleSection: React.FC<{
|
||||||
|
header: HTMLTableRowElement;
|
||||||
|
childrenRows: HTMLTableRowElement[];
|
||||||
|
}> = ({ header, childrenRows }) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Hide children rows initially
|
||||||
|
childrenRows.forEach(row => (row.style.display = open ? '' : 'none'));
|
||||||
|
// Clean up on unmount
|
||||||
|
return () => {
|
||||||
|
childrenRows.forEach(row => (row.style.display = ''));
|
||||||
|
};
|
||||||
|
}, [open, childrenRows]);
|
||||||
|
|
||||||
|
// Inject a button into the header cell
|
||||||
|
useEffect(() => {
|
||||||
|
const cell = header.querySelector('td.course_header');
|
||||||
|
if (!cell) return;
|
||||||
|
let button = cell.querySelector('.utrp-collapse-btn') as HTMLButtonElement | null;
|
||||||
|
if (!button) {
|
||||||
|
button = document.createElement('button');
|
||||||
|
button.className = 'utrp-collapse-btn';
|
||||||
|
button.style.marginRight = '8px';
|
||||||
|
cell.prepend(button);
|
||||||
|
}
|
||||||
|
button.textContent = open ? '▼' : '►';
|
||||||
|
button.onclick = () => setOpen(o => !o);
|
||||||
|
// Clean up
|
||||||
|
return () => {
|
||||||
|
button?.remove();
|
||||||
|
};
|
||||||
|
}, [header, open]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default
|
||||||
@@ -215,7 +215,7 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
|
|||||||
options={{
|
options={{
|
||||||
...chartOptions,
|
...chartOptions,
|
||||||
title: {
|
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 },
|
tooltip: { enabled: false },
|
||||||
}}
|
}}
|
||||||
@@ -228,7 +228,7 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
|
|||||||
<Text variant='small' className='text-ut-black'>
|
<Text variant='small' className='text-ut-black'>
|
||||||
Grade Distribution for{' '}
|
Grade Distribution for{' '}
|
||||||
<Text variant='small' className='font-extrabold!' as='strong'>
|
<Text variant='small' className='font-extrabold!' as='strong'>
|
||||||
{course.department} {course.number}
|
{course.department} {course.getNumberWithoutTerm()}
|
||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
<select
|
<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'>
|
<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'>
|
<Text variant='small' className='text-theme-red'>
|
||||||
We couldn't find {semester !== 'Aggregate' && ` ${semester}`} grades for this
|
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>
|
</Text>
|
||||||
</div>
|
</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();
|
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 [status, isReserved] = this.getStatus(row);
|
||||||
|
|
||||||
const newCourse = new Course({
|
const newCourse = new Course({
|
||||||
@@ -113,16 +113,31 @@ export class CourseCatalogScraper {
|
|||||||
*
|
*
|
||||||
* @example
|
* @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")
|
* @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
|
* @returns an array of the course name, department, and number
|
||||||
*/
|
*/
|
||||||
separateCourseName(courseFullName: string): [courseName: string, department: string, number: string] {
|
static separateCourseName(courseFullName: string): [courseName: string, department: string, number: string] {
|
||||||
let courseNumberIndex = courseFullName.search(/\d/);
|
// C S 314H DATA STRUCTURES: HONORS
|
||||||
let department = courseFullName.substring(0, courseNumberIndex).trim();
|
// ^ Here for normal courses
|
||||||
let number = courseFullName.substring(courseNumberIndex, courseFullName.indexOf(' ', courseNumberIndex)).trim();
|
// B A n284S 1-MANAGERIAL MICROECON-I-DAL (Nine week term)
|
||||||
let courseName = courseFullName.substring(courseFullName.indexOf(' ', courseNumberIndex)).trim();
|
// ^ 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];
|
return [courseName, department, number];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,16 +109,22 @@ function generateQuery(
|
|||||||
includeInstructor: boolean
|
includeInstructor: boolean
|
||||||
): [string, GradeDistributionParams] {
|
): [string, GradeDistributionParams] {
|
||||||
const query = `
|
const query = `
|
||||||
select * from grade_distributions
|
SELECT * FROM grade_distributions
|
||||||
where Department_Code = :department_code
|
WHERE Department_Code = :department_code
|
||||||
and Course_Number = :course_number
|
AND Course_Number COLLATE NOCASE IN (
|
||||||
${includeInstructor ? `and Instructor_Last = :instructor_last collate nocase` : ''}
|
:course_number,
|
||||||
${semester ? `and Semester = :semester` : ''}
|
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 = {
|
const params: GradeDistributionParams = {
|
||||||
':department_code': course.department,
|
':department_code': course.department,
|
||||||
':course_number': course.number,
|
':course_number': course.getNumberWithoutTerm(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (includeInstructor) {
|
if (includeInstructor) {
|
||||||
|
|||||||
Reference in New Issue
Block a user