Compare commits

..

6 Commits

Author SHA1 Message Date
a423c6ed4e feat: initial commit bs code 2025-06-15 20:41:37 -05:00
doprz
7b401add15 feat: add nix flake (#593)
Co-authored-by: Samuel Gunter <29130894+Samathingamajig@users.noreply.github.com>
2025-06-08 23:16:43 -05:00
Samuel Gunter
2d92dd47f0 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>
2025-06-08 21:10:05 -07:00
Aiyaz Mostofa
eb8141ee8c fix: limit height of schedule list dropdown in the extension popup (#543)
* fix: limit height of schedule list dropdown in the extension popup

* fix: limit the entire dropdown to 200px, not just the schedule list

* fix: use flexbox for dropdown and wedge scrollbar inside margin

* fix: use DisclosurePanel in schedule dropdown, do Uno class sorting

---------

Co-authored-by: Ethan Lanting <ethanlanting@gmail.com>
Co-authored-by: Samuel Gunter <29130894+Samathingamajig@users.noreply.github.com>
Co-authored-by: doprz <52579214+doprz@users.noreply.github.com>
2025-06-08 20:54:36 -07:00
sjalkote
2a50f5580d feat: automatically select new or duplicated schedules (#583) (#589)
Co-authored-by: doprz <52579214+doprz@users.noreply.github.com>
Co-authored-by: Samuel Gunter <29130894+Samathingamajig@users.noreply.github.com>
2025-06-08 20:39:46 -07:00
doprz
65bfb1d129 docs: add pnpm and update versions (#597) 2025-06-08 15:57:12 -05:00
18 changed files with 377 additions and 39 deletions

View File

@@ -7,3 +7,6 @@ insert_final_newline = true
trim_trailing_whitespace = true
indent_size = 4
indent_style = space
[*.nix]
indent_size = 2

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use flake

2
.gitignore vendored
View File

@@ -211,3 +211,5 @@ sketch
package-lock.json
storybook-static/
package/
.direnv/

View File

@@ -26,8 +26,9 @@
## Toolchain
- React v20.9.0 (LTS)
- TypeScript
- Vite 5
- TypeScript v5.x
- Vite v5.x
- pnpm v10.x
- UnoCSS
- ESLint
- 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:
- React v20.9.0 (LTS)
- TypeScript
- Vite 5
- TypeScript v5.x
- Vite v5.x
- pnpm v10.x
- UnoCSS
- ESLint
- Prettier

61
flake.lock generated Normal file
View 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
View 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)"
'';
};
}
);
}

View File

@@ -37,6 +37,9 @@ export default async function createSchedule(scheduleName: string) {
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 (schedules.length <= 1) {
await UserScheduleStore.set('activeIndex', 0);

View File

@@ -31,5 +31,9 @@ export default async function duplicateSchedule(scheduleId: string): Promise<str
} satisfies typeof schedule);
await UserScheduleStore.set('schedules', schedules);
// Automatically switch to the duplicated schedule
await UserScheduleStore.set('activeIndex', scheduleIndex + 1);
return undefined;
}

View File

@@ -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
}
}
/**

View 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);
});
});

View File

@@ -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'>

View File

@@ -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} />
)}
/>
)}

View File

@@ -19,7 +19,7 @@ export default function ScheduleDropdown({ defaultOpen, children }: ScheduleDrop
const [activeSchedule] = useSchedules();
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}>
{({ open }) => (
<>
@@ -54,17 +54,17 @@ export default function ScheduleDropdown({ defaultOpen, children }: ScheduleDrop
<Transition
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'
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'
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'
>
<div className='px-3.5 pb-2.5 pt-2'>
<DisclosurePanel>{children}</DisclosurePanel>
</div>
<DisclosurePanel className='mx-1.75 mb-2.5 mt-2 flex flex-1 flex-col overflow-y-auto'>
<div className='mx-1.75'>{children}</div>
</DisclosurePanel>
</Transition>
</>
)}

View File

@@ -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

View File

@@ -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&apos;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>
)}

View 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);
});
});

View File

@@ -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];
}

View File

@@ -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) {