Compare commits

..

19 Commits

Author SHA1 Message Date
doprz
c21cbd77f0 feat(release): v2.2.2 2025-10-13 11:20:01 -05:00
99a035e29d chore: remove summer 2025 schedule list item (#637) 2025-10-13 02:25:27 -05:00
doprz
64baa6d290 refactor(nix): dev shells (#634) 2025-10-12 22:47:47 -05:00
Samuel Gunter
46fe591fa7 fix: whitespace wrapping in semester warning (#629) 2025-10-07 18:28:41 -05:00
doprz
8f7e1bc0af feat(env): add SENTRY env vars 2025-10-07 16:11:14 -05:00
doprz
9fc1098ef7 chore(env): add .env.example 2025-10-07 16:00:40 -05:00
Warith Rahman
ae094416fc chore(ui): added spring 2026 course schedule (#628) 2025-10-07 15:26:15 -05:00
ishita778
2e7dac1e3e feat: show warning for courses of different semesters (#570)
* chore: removed extra space at calendar footer

* chore: fixed eslint issues

* chore: changed return type to react node

* chore: displaycourses true fixes and checks fixed

* chore: prettier fix

* feat: not working same semester course issue

* feat: modifying components to use the new hook

* feat: small fixes

* fix: remove comments and spaces

* fix: dialog error solved

* fix: add to new schedule

* fix: prettier

* fix: delete unnecessary custom hook and p[rettier

* fix: checks all passing

* fix: added requested changes

* fix: added new conditions

* fix: description fixed

* style: fix Roboto Flex not being used as font in dialog

* fix: made requested changes

---------

Co-authored-by: doprz <52579214+doprz@users.noreply.github.com>
Co-authored-by: Razboy20 <razboy20@gmail.com>
Co-authored-by: Samuel Gunter <29130894+Samathingamajig@users.noreply.github.com>
2025-08-27 13:41:36 -05:00
doprz
7bea23a655 refactor: nix flake (#625)
* chore(nix): update flake

* chore(nix): remove just and update comment

* chore(nix): update node version + volta config

* refactor: nix flake
2025-08-25 11:33:44 -07:00
Samuel Gunter
3d28869e92 chore: spring 2025 grades (#624) 2025-08-18 00:29:59 -05:00
doprz
f0f1f0b365 chore: bump node and pnpm version (#620)
* chore(nix): update flake

* chore(nix): remove just and update comment

* chore(nix): update node version + volta config
2025-08-12 14:00:45 -05:00
be861b823c feat: search result shading (#617)
* feat: site support kws

* feat: function

* feat: stuff before bedtime

* feat: shading function

* feat: shading

* feat: shading the table children

* chore: fix lint issues

* feat: dependency array

* feat: remove

* feat: remove temp console log
2025-08-07 13:28:56 -05:00
Samuel Gunter
95de8df372 fix: fix or ignore various eslint warning (#609) 2025-07-16 07:54:40 -07:00
5994ded8be feat: export schedule button add to calendar (#594)
* feat: export schedule button add to calendar add to util too

* docs: hypen bruh

* chore: lowercase

* style: filecode icon

* chore: unused import

* refactor: use export json deleted old function

* chore: linting

* chore: remove useless import

---------

Co-authored-by: Samuel Gunter <29130894+Samathingamajig@users.noreply.github.com>
2025-06-17 11:57:48 -07: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
37 changed files with 554 additions and 108 deletions

View File

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

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
SENTRY_ORG=longhorn-developers
SENTRY_PROJECT=ut-registration-plus
SENTRY_AUTH_TOKEN=

1
.envrc Normal file
View File

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

2
.gitignore vendored
View File

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

View File

@@ -1,9 +1,27 @@
## [2.2.2](https://github.com/Longhorn-Developers/UT-Registration-Plus/compare/v2.2.1...v2.2.2) (2025-10-13)
### Features
* add nix flake ([#593](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/593)) ([7b401ad](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/7b401add1565ff401bad99745ff9e53b9a7f899f))
* automatically select new or duplicated schedules ([#583](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/583)) ([#589](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/589)) ([2a50f55](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/2a50f5580d3dbeb0d66546c23cf29bbb37d80da2))
* **env:** add SENTRY env vars ([8f7e1bc](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/8f7e1bc0af6336549068e02b80df21d4e8f4ef9c))
* export schedule button add to calendar ([#594](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/594)) ([5994ded](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/5994ded8be876cb55174d27d3fdb0832b21a0ff9))
* search result shading ([#617](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/617)) ([be861b8](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/be861b823cb2cb7f6f4a1f266351eec3fc1c2f99))
* show warning for courses of different semesters ([#570](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/570)) ([2e7dac1](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/2e7dac1e3eba757231ac07ac966231c08c703a16))
* support summer grades, fix summer course parser ([#596](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/596)) ([2d92dd4](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/2d92dd47f00a44b7d48e92a8ffba94480e4e73f9))
### Bug Fixes
* fix or ignore various eslint warning ([#609](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/609)) ([95de8df](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/95de8df37243b6d59625df515a60442f11b7a9d3))
* limit height of schedule list dropdown in the extension popup ([#543](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/543)) ([eb8141e](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/eb8141ee8c3d32bce901457178d50781b78f86dd))
* whitespace wrapping in semester warning ([#629](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/629)) ([46fe591](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/46fe591fa72ef017eea7cfb8aa37d12d8f223926))
## [2.2.1](https://github.com/Longhorn-Developers/UT-Registration-Plus/compare/v2.2.0...v2.2.1) (2025-06-04) ## [2.2.1](https://github.com/Longhorn-Developers/UT-Registration-Plus/compare/v2.2.0...v2.2.1) (2025-06-04)
### Features ### Features
* add dining app promo ([#598](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/598)) ([be1dccf](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/be1dccfcb9d052c6b291b50cc53418d6bb645beb)) * add dining app promo ([#598](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/598)) ([be1dccf](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/be1dccfcb9d052c6b291b50cc53418d6bb645beb))
* inside jokes005 ([#590](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/590)) ([37471ef](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/37471efb740c7a5828cf3b54bac70954694359d7)) * inside jokes005 ([#590](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/590)) ([37471ef](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/37471efb740c7a5828cf3b54bac70954694359d7))
* **release:** v2.2.1 ([234f3d6](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/234f3d627d603adf8555b4d0e93106d198918169))
### Bug Fixes ### Bug Fixes

View File

@@ -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
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": 1759831965,
"narHash": "sha256-vgPm2xjOmKdZ0xKA6yLXPJpjOtQPHfaZDRtH+47XEBo=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c9b6fb798541223bbb396d287d16f43520250518",
"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
}

43
flake.nix Normal file
View File

@@ -0,0 +1,43 @@
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs =
{
self,
nixpkgs,
flake-utils,
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = (import nixpkgs { inherit system; });
commonPackages = with pkgs; [
nodejs_20 # v20.19.5
pnpm_10 # v10.18.0
];
additionalPackages = with pkgs; [
bun
nodePackages.conventional-changelog-cli
sentry-cli
];
in
{
formatter = pkgs.nixfmt-rfc-style;
devShells.default = pkgs.mkShell {
name = "utrp-dev";
buildInputs = commonPackages;
};
devShells.full = pkgs.mkShell {
name = "utrp-dev-full";
buildInputs = commonPackages ++ additionalPackages;
};
}
);
}

View File

@@ -1,7 +1,7 @@
{ {
"name": "ut-registration-plus", "name": "ut-registration-plus",
"displayName": "UT Registration Plus", "displayName": "UT Registration Plus",
"version": "2.2.1", "version": "2.2.2",
"description": "UT Registration Plus is a Chrome extension that allows students to easily register for classes.", "description": "UT Registration Plus is a Chrome extension that allows students to easily register for classes.",
"private": true, "private": true,
"homepage": "https://github.com/Longhorn-Developers/UT-Registration-Plus", "homepage": "https://github.com/Longhorn-Developers/UT-Registration-Plus",
@@ -162,7 +162,7 @@
} }
}, },
"volta": { "volta": {
"node": "20.9.0", "node": "20.19.4",
"pnpm": "10.6.5" "pnpm": "10.14.0"
} }
} }

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import CourseCatalogMain from '@views/components/CourseCatalogMain'; import CourseCatalogMain from '@views/components/CourseCatalogMain';
import InjectedButton from '@views/components/injected/AddAllButton'; import InjectedButton from '@views/components/injected/AddAllButton';
import DaysCheckbox from '@views/components/injected/DaysCheckbox'; import DaysCheckbox from '@views/components/injected/DaysCheckbox';
import ShadedResults from '@views/components/injected/SearchResultShader';
import getSiteSupport, { SiteSupport } from '@views/lib/getSiteSupport'; import getSiteSupport, { SiteSupport } from '@views/lib/getSiteSupport';
import React from 'react'; import React from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
@@ -30,3 +31,7 @@ if (support === SiteSupport.MY_UT) {
if (support === SiteSupport.COURSE_CATALOG_SEARCH) { if (support === SiteSupport.COURSE_CATALOG_SEARCH) {
renderComponent(DaysCheckbox); renderComponent(DaysCheckbox);
} }
if (support === SiteSupport.COURSE_CATALOG_KWS) {
renderComponent(ShadedResults);
}

View File

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

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); 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'>

View File

@@ -15,6 +15,8 @@ import type { SiteSupportType } from '@views/lib/getSiteSupport';
import { populateSearchInputs } from '@views/lib/populateSearchInputs'; import { populateSearchInputs } from '@views/lib/populateSearchInputs';
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import DialogProvider from './common/DialogProvider/DialogProvider';
interface Props { interface Props {
support: Extract<SiteSupportType, 'COURSE_CATALOG_DETAILS' | 'COURSE_CATALOG_LIST'>; support: Extract<SiteSupportType, 'COURSE_CATALOG_DETAILS' | 'COURSE_CATALOG_LIST'>;
} }
@@ -82,28 +84,30 @@ export default function CourseCatalogMain({ support }: Props): JSX.Element | nul
return ( return (
<ExtensionRoot> <ExtensionRoot>
<NewSearchLink /> <DialogProvider>
<RecruitmentBanner /> <NewSearchLink />
<TableHead>Plus</TableHead> <RecruitmentBanner />
{rows.map( <TableHead>Plus</TableHead>
row => {rows.map(
row.course && ( row =>
<TableRow row.course && (
key={row.course.uniqueId} <TableRow
row={row} key={row.course.uniqueId}
isSelected={row.course.uniqueId === selectedCourse?.uniqueId} row={row}
activeSchedule={activeSchedule} isSelected={row.course.uniqueId === selectedCourse?.uniqueId}
onClick={handleRowButtonClick(row.course)} activeSchedule={activeSchedule}
/> onClick={handleRowButtonClick(row.course)}
) />
)} )
<CourseCatalogInjectedPopup )}
course={selectedCourse!} // always defined when showPopup is true <CourseCatalogInjectedPopup
show={showPopup} course={selectedCourse!} // always defined when showPopup is true
onClose={() => setShowPopup(false)} show={showPopup}
afterLeave={() => setSelectedCourse(null)} onClose={() => setShowPopup(false)}
/> afterLeave={() => setSelectedCourse(null)}
{enableScrollToLoad && <AutoLoad addRows={addRows} />} />
{enableScrollToLoad && <AutoLoad addRows={addRows} />}
</DialogProvider>
</ExtensionRoot> </ExtensionRoot>
); );
} }

View File

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

View File

@@ -27,12 +27,10 @@ export default function HexColorEditor({ hexCode, setHexCode }: HexColorEditorPr
const tagColor = pickFontColor(previewColor.slice(1) as `#${string}`); const tagColor = pickFontColor(previewColor.slice(1) as `#${string}`);
const [localHexCode, setLocalHexCode] = React.useState(hexCode); const [localHexCode, setLocalHexCode] = React.useState(hexCode);
const debouncedSetHexCode = useDebounce((value: string) => setHexCode(value), 500); const debouncedSetHexCode = useDebounce(setHexCode, 500);
React.useEffect(() => { React.useEffect(() => {
if (hexCode !== localHexCode) { setLocalHexCode(hexCode);
setLocalHexCode(hexCode);
}
}, [hexCode]); }, [hexCode]);
React.useEffect(() => { React.useEffect(() => {

View File

@@ -1,5 +1,5 @@
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'; import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react';
import { CalendarDots, Export, FilePng, Sidebar } from '@phosphor-icons/react'; import { CalendarDots, Export, FileCode, FilePng, Sidebar } from '@phosphor-icons/react';
import styles from '@views/components/calendar/CalendarHeader/CalendarHeader.module.scss'; import styles from '@views/components/calendar/CalendarHeader/CalendarHeader.module.scss';
import { Button } from '@views/components/common/Button'; import { Button } from '@views/components/common/Button';
import DialogProvider from '@views/components/common/DialogProvider/DialogProvider'; import DialogProvider from '@views/components/common/DialogProvider/DialogProvider';
@@ -11,7 +11,7 @@ import useSchedules from '@views/hooks/useSchedules';
import clsx from 'clsx'; import clsx from 'clsx';
import React from 'react'; import React from 'react';
import { saveAsCal, saveCalAsPng } from '../utils'; import { handleExportJson, saveAsCal, saveCalAsPng } from '../utils';
interface CalendarHeaderProps { interface CalendarHeaderProps {
sidebarOpen?: boolean; sidebarOpen?: boolean;
@@ -98,6 +98,18 @@ export default function CalendarHeader({ sidebarOpen, onSidebarToggle }: Calenda
Save as .cal Save as .cal
</Button> </Button>
</MenuItem> </MenuItem>
<MenuItem>
<Button
className='w-full flex justify-start'
onClick={() => handleExportJson(activeSchedule.id)}
color='ut-black'
size='small'
variant='minimal'
icon={FileCode}
>
Save as .json
</Button>
</MenuItem>
{/* <MenuItem> {/* <MenuItem>
<Button color='ut-black' size='small' variant='minimal' icon={FileTxt}> <Button color='ut-black' size='small' variant='minimal' icon={FileTxt}>
Export Unique IDs Export Unique IDs

View File

@@ -1,5 +1,5 @@
import { AppStoreLogo, ForkKnife, X as CloseIcon } from '@phosphor-icons/react'; import { AppStoreLogo, ForkKnife, X as CloseIcon } from '@phosphor-icons/react';
import { UT_DINING_APP_STORE_URL, UT_DINING_GOOGLE_PLAY_URL } from '@shared/util/appUrls'; import { UT_DINING_APP_STORE_URL } from '@shared/util/appUrls';
import { Button } from '@views/components/common/Button'; import { Button } from '@views/components/common/Button';
import Text from '@views/components/common/Text/Text'; import Text from '@views/components/common/Text/Text';
import React from 'react'; import React from 'react';

View File

@@ -14,18 +14,14 @@ interface LinkItem {
} }
const links: LinkItem[] = [ const links: LinkItem[] = [
{
text: "Spring '26 Course Schedule",
url: 'https://utdirect.utexas.edu/apps/registrar/course_schedule/20262/',
},
{ {
text: "Fall '25 Course Schedule", text: "Fall '25 Course Schedule",
url: 'https://utdirect.utexas.edu/apps/registrar/course_schedule/20259/', url: 'https://utdirect.utexas.edu/apps/registrar/course_schedule/20259/',
}, },
{
text: "Summer '25 Course Schedule",
url: 'https://utdirect.utexas.edu/apps/registrar/course_schedule/20256/',
},
// {
// text: "Spring '25 Course Schedule",
// url: 'https://utdirect.utexas.edu/apps/registrar/course_schedule/20252/',
// },
{ {
text: 'Course Schedule Archives', text: 'Course Schedule Archives',
url: 'https://registrar.utexas.edu/schedules/archive', url: 'https://registrar.utexas.edu/schedules/archive',
@@ -34,10 +30,10 @@ const links: LinkItem[] = [
text: 'My Degree Audit (IDA)', text: 'My Degree Audit (IDA)',
url: 'https://utdirect.utexas.edu/apps/degree/audits/', url: 'https://utdirect.utexas.edu/apps/degree/audits/',
}, },
// { {
// text: "'24-'25 Academic Calendar", text: "'25-'26 Academic Calendar",
// url: 'https://registrar.utexas.edu/calendars/24-25', url: 'https://registrar.utexas.edu/calendars/25-26',
// }, },
{ {
text: 'Registration Info Sheet (RIS)', text: 'Registration Info Sheet (RIS)',
url: 'https://utdirect.utexas.edu/registrar/ris.WBX', url: 'https://utdirect.utexas.edu/registrar/ris.WBX',

View File

@@ -1,4 +1,5 @@
import { tz, TZDate } from '@date-fns/tz'; import { tz, TZDate } from '@date-fns/tz';
import exportSchedule from '@pages/background/lib/exportSchedule';
import { UserScheduleStore } from '@shared/storage/UserScheduleStore'; import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
import type { Course } from '@shared/types/Course'; import type { Course } from '@shared/types/Course';
import type { CourseMeeting } from '@shared/types/CourseMeeting'; import type { CourseMeeting } from '@shared/types/CourseMeeting';
@@ -261,6 +262,22 @@ export const saveAsCal = async () => {
downloadBlob(icsString, 'CALENDAR', 'schedule.ics'); downloadBlob(icsString, 'CALENDAR', 'schedule.ics');
}; };
/**
* Saves current schedule to JSON that can be imported on other devices.
* @param id - Provided schedule ID to download
*/
export const handleExportJson = async (id: string) => {
const jsonString = await exportSchedule(id);
if (jsonString) {
const schedules = await UserScheduleStore.get('schedules');
const schedule = schedules.find(s => s.id === id);
const fileName = `${schedule?.name ?? `schedule_${id}`}_${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
await downloadBlob(jsonString, 'JSON', fileName);
} else {
console.error('Error exporting schedule: jsonString is undefined');
}
};
/** /**
* Saves the calendar as a PNG image. * Saves the calendar as a PNG image.
* *

View File

@@ -15,6 +15,11 @@
@apply font-sans; @apply font-sans;
color: #303030; color: #303030;
// fix font-family on injected pages
* {
@apply font-sans;
}
[data-rfd-drag-handle-context-id=':r1:'] { [data-rfd-drag-handle-context-id=':r1:'] {
cursor: move; cursor: move;
} }

View File

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

View File

@@ -15,7 +15,7 @@ import React, { useEffect, useState } from 'react';
*/ */
const WHATSNEW_POPUP_VERSION = 2; const WHATSNEW_POPUP_VERSION = 2;
const WHATSNEW_VIDEO_URL = 'https://cdn.longhorns.dev/whats-new-v2.1.2.mp4'; // const WHATSNEW_VIDEO_URL = 'https://cdn.longhorns.dev/whats-new-v2.1.2.mp4';
type Feature = { type Feature = {
id: string; id: string;
@@ -60,7 +60,7 @@ const NEW_FEATURES = [
* @returns A JSX of WhatsNewPopupContent component. * @returns A JSX of WhatsNewPopupContent component.
*/ */
export default function WhatsNewPopupContent(): JSX.Element { export default function WhatsNewPopupContent(): JSX.Element {
const [videoError, setVideoError] = useState(false); const [videoError, _setVideoError] = useState(false);
return ( return (
<div className='w-full flex flex-row justify-between'> <div className='w-full flex flex-row justify-between'>

View File

@@ -1,6 +1,5 @@
import { addCourseByURL } from '@pages/background/lib/addCourseByURL'; import { addCourseByURL } from '@pages/background/lib/addCourseByURL';
import { background } from '@shared/messages'; import { background } from '@shared/messages';
import { validateLoginStatus } from '@shared/util/checkLoginStatus';
import { Button } from '@views/components/common/Button'; import { Button } from '@views/components/common/Button';
import ExtensionRoot from '@views/components/common/ExtensionRoot/ExtensionRoot'; import ExtensionRoot from '@views/components/common/ExtensionRoot/ExtensionRoot';
import useSchedules from '@views/hooks/useSchedules'; import useSchedules from '@views/hooks/useSchedules';
@@ -43,6 +42,8 @@ export default function InjectedButton(): JSX.Element | null {
await addCourseByURL(activeSchedule, a); await addCourseByURL(activeSchedule, a);
} }
} else { } else {
// We'll allow the alert for this WIP feature
// eslint-disable-next-line no-alert
window.alert('Logged into UT Registrar.'); window.alert('Logged into UT Registrar.');
} }
}; };

View File

@@ -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&apos;t find {semester !== 'Aggregate' && ` ${semester}`} grades for this 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> </Text>
</div> </div>
)} )}

View File

@@ -1,3 +1,5 @@
import createSchedule from '@pages/background/lib/createSchedule';
import switchSchedule from '@pages/background/lib/switchSchedule';
import { import {
ArrowUpRight, ArrowUpRight,
CalendarDots, CalendarDots,
@@ -14,8 +16,10 @@ import { background } from '@shared/messages';
import type { Course } from '@shared/types/Course'; import type { Course } from '@shared/types/Course';
import type Instructor from '@shared/types/Instructor'; import type Instructor from '@shared/types/Instructor';
import type { UserSchedule } from '@shared/types/UserSchedule'; import type { UserSchedule } from '@shared/types/UserSchedule';
import { englishStringifyList } from '@shared/util/string';
import { Button } from '@views/components/common/Button'; import { Button } from '@views/components/common/Button';
import { Chip, coreMap, flagMap } from '@views/components/common/Chip'; import { Chip, coreMap, flagMap } from '@views/components/common/Chip';
import { usePrompt } from '@views/components/common/DialogProvider/DialogProvider';
import Divider from '@views/components/common/Divider'; import Divider from '@views/components/common/Divider';
import Link from '@views/components/common/Link'; import Link from '@views/components/common/Link';
import Text from '@views/components/common/Text/Text'; import Text from '@views/components/common/Text/Text';
@@ -60,7 +64,7 @@ export default function HeadingAndActions({ course, activeSchedule, onClose }: H
const [isCopied, setIsCopied] = useState<boolean>(false); const [isCopied, setIsCopied] = useState<boolean>(false);
const lastCopyTime = useRef<number>(0); const lastCopyTime = useRef<number>(0);
const showDialog = usePrompt();
const getInstructorFullName = (instructor: Instructor) => instructor.toString({ format: 'first_last' }); const getInstructorFullName = (instructor: Instructor) => instructor.toString({ format: 'first_last' });
const handleCopy = async (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => { const handleCopy = async (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
@@ -112,10 +116,78 @@ export default function HeadingAndActions({ course, activeSchedule, onClose }: H
} }
}; };
const handleAddToNewSchedule = async (close: () => void) => {
const newScheduleId = await createSchedule(`${course.semester.season} ${course.semester.year}`);
switchSchedule(newScheduleId);
addCourse({ course, scheduleId: newScheduleId });
close();
};
const handleAddOrRemoveCourse = async () => { const handleAddOrRemoveCourse = async () => {
const uniqueSemesterCodes = [
...new Set(
activeSchedule.courses
.map(course => course.semester.code)
.filter((code): code is string => code !== undefined)
),
];
uniqueSemesterCodes.sort();
const codeToReadableMap: Record<string, string> = {};
activeSchedule.courses.forEach(course => {
const { code } = course.semester;
if (code) {
const readable = `${course.semester.season} ${course.semester.year}`;
codeToReadableMap[code] = readable;
}
});
const sortedSemesters = uniqueSemesterCodes
.map(code => codeToReadableMap[code])
.filter((value): value is string => value !== undefined);
const activeSemesters = englishStringifyList(sortedSemesters);
if (!activeSchedule) return; if (!activeSchedule) return;
if (!courseAdded) { if (!courseAdded) {
addCourse({ course, scheduleId: activeSchedule.id }); const currentSemesterCode = course.semester.code;
// Show warning if this course is for a different semester than the selected schedule
if (
activeSchedule.courses.length > 0 &&
activeSchedule.courses.every(otherCourse => otherCourse.semester.code !== currentSemesterCode)
) {
const dialogButtons = (close: () => void) => (
<>
<Button variant='minimal' color='ut-black' onClick={close}>
Cancel
</Button>
<Button
variant='filled'
color='ut-burntorange'
onClick={() => {
handleAddToNewSchedule(close);
}}
>
Start a new schedule
</Button>
</>
);
showDialog({
title: 'This course section is from a different semester!',
description: (
<>
The section you&apos;re adding is for{' '}
<span className='text-ut-burntorange whitespace-nowrap'>
{course.semester.season} {course.semester.year}
</span>
, but your current schedule contains sections in{' '}
<span className='text-ut-burntorange whitespace-nowrap'>{activeSemesters}</span>. Mixing
semesters in one schedule may cause confusion.
</>
),
buttons: dialogButtons,
});
} else {
addCourse({ course, scheduleId: activeSchedule.id });
}
} else { } else {
removeCourse({ course, scheduleId: activeSchedule.id }); removeCourse({ course, scheduleId: activeSchedule.id });
} }

View File

@@ -0,0 +1,39 @@
import { useEffect } from 'react';
// @TODO Get a better name for this class
/**
* The existing search results (kws), only with alternate shading for easier readability
*
*/
export default function ShadedResults(): null {
useEffect(() => {
const table = document.getElementById('kw_results_table');
if (!table) {
console.error('Results table not found');
return;
}
const tbody = table.querySelector('tbody');
if (!tbody) {
console.error('Table tbody not found');
return;
}
const style = document.createElement('style');
style.textContent = `
#kw_results_table tbody tr:nth-child(even) {
background-color: #f0f0f0 !important;
}
#kw_results_table tbody tr:nth-child(even) td {
background-color: #f0f0f0 !important;
}
`;
document.head.appendChild(style);
return () => {
style.remove();
};
}, []);
return null;
}

View File

@@ -1,16 +1,13 @@
// import addCourse from '@pages/background/lib/addCourse'; // import addCourse from '@pages/background/lib/addCourse';
import { addCourseByURL } from '@pages/background/lib/addCourseByURL'; import { addCourseByURL } from '@pages/background/lib/addCourseByURL';
import { deleteAllSchedules } from '@pages/background/lib/deleteSchedule'; import { deleteAllSchedules } from '@pages/background/lib/deleteSchedule';
import exportSchedule from '@pages/background/lib/exportSchedule';
import importSchedule from '@pages/background/lib/importSchedule'; import importSchedule from '@pages/background/lib/importSchedule';
import { CalendarDots, Trash } from '@phosphor-icons/react'; import { CalendarDots, Trash } from '@phosphor-icons/react';
import { background } from '@shared/messages'; import { background } from '@shared/messages';
import { DevStore } from '@shared/storage/DevStore'; import { DevStore } from '@shared/storage/DevStore';
import { initSettings, OptionsStore } from '@shared/storage/OptionsStore'; import { initSettings, OptionsStore } from '@shared/storage/OptionsStore';
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
import { CRX_PAGES } from '@shared/types/CRXPages'; import { CRX_PAGES } from '@shared/types/CRXPages';
import MIMEType from '@shared/types/MIMEType'; import MIMEType from '@shared/types/MIMEType';
import { downloadBlob } from '@shared/util/downloadBlob';
// import { addCourseByUrl } from '@shared/util/courseUtils'; // import { addCourseByUrl } from '@shared/util/courseUtils';
// import { getCourseColors } from '@shared/util/colors'; // import { getCourseColors } from '@shared/util/colors';
// import CalendarCourseCell from '@views/components/calendar/CalendarCourseCell'; // import CalendarCourseCell from '@views/components/calendar/CalendarCourseCell';
@@ -32,6 +29,7 @@ import React, { useCallback, useEffect, useState } from 'react';
import IconoirGitFork from '~icons/iconoir/git-fork'; import IconoirGitFork from '~icons/iconoir/git-fork';
import { handleExportJson } from '../calendar/utils';
// import { ExampleCourse } from 'src/stories/components/ConflictsWithWarning.stories';; // import { ExampleCourse } from 'src/stories/components/ConflictsWithWarning.stories';;
import FileUpload from '../common/FileUpload'; import FileUpload from '../common/FileUpload';
import { useMigrationDialog } from '../common/MigrationDialog'; import { useMigrationDialog } from '../common/MigrationDialog';
@@ -232,18 +230,6 @@ export default function Settings(): JSX.Element {
}); });
}; };
const handleExportClick = async (id: string) => {
const jsonString = await exportSchedule(id);
if (jsonString) {
const schedules = await UserScheduleStore.get('schedules');
const schedule = schedules.find(s => s.id === id);
const fileName = `${schedule?.name ?? `schedule_${id}`}_${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
await downloadBlob(jsonString, 'JSON', fileName);
} else {
console.error('Error exporting schedule: jsonString is undefined');
}
};
const handleImportClick = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleImportClick = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (file) { if (file) {
@@ -400,7 +386,7 @@ export default function Settings(): JSX.Element {
<Button <Button
variant='outline' variant='outline'
color='ut-burntorange' color='ut-burntorange'
onClick={() => handleExportClick(activeSchedule.id)} onClick={() => handleExportJson(activeSchedule.id)}
> >
Export Export
</Button> </Button>

View File

@@ -5,8 +5,7 @@ import WhatsNewPopupContent from '@views/components/common/WhatsNewPopup';
import { useDialog } from '@views/contexts/DialogContext'; import { useDialog } from '@views/contexts/DialogContext';
import React from 'react'; import React from 'react';
import { LogoIcon } from '../components/common/LogoIcon'; // import useChangelog from './useChangelog';
import useChangelog from './useChangelog';
const LDIconURL = new URL('/src/assets/LD-icon-new.png', import.meta.url).href; const LDIconURL = new URL('/src/assets/LD-icon-new.png', import.meta.url).href;
@@ -17,8 +16,8 @@ const LDIconURL = new URL('/src/assets/LD-icon-new.png', import.meta.url).href;
*/ */
export default function useWhatsNewPopUp(): () => void { export default function useWhatsNewPopUp(): () => void {
const showDialog = useDialog(); const showDialog = useDialog();
const showChangeLog = useChangelog(); // const showChangeLog = useChangelog();
const { version } = chrome.runtime.getManifest(); // const { version } = chrome.runtime.getManifest();
const showPopUp = () => { const showPopUp = () => {
showDialog(close => ({ showDialog(close => ({

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(); 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];
} }

View File

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

View File

@@ -15,6 +15,7 @@ export const SiteSupport = {
MY_UT: 'MY_UT', MY_UT: 'MY_UT',
COURSE_CATALOG_SEARCH: 'COURSE_CATALOG_SEARCH', COURSE_CATALOG_SEARCH: 'COURSE_CATALOG_SEARCH',
CLASSLIST: 'CLASSLIST', CLASSLIST: 'CLASSLIST',
COURSE_CATALOG_KWS: 'COURSE_CATALOG_KWS',
} as const; } as const;
/** /**
@@ -40,6 +41,9 @@ export default function getSiteSupport(url: string): SiteSupportType | null {
return SiteSupport.UT_PLANNER; return SiteSupport.UT_PLANNER;
} }
if (url.includes('utdirect.utexas.edu/apps/registrar/course_schedule')) { if (url.includes('utdirect.utexas.edu/apps/registrar/course_schedule')) {
if (url.includes('kws_results')) {
return SiteSupport.COURSE_CATALOG_KWS;
}
if (url.includes('results')) { if (url.includes('results')) {
return SiteSupport.COURSE_CATALOG_LIST; return SiteSupport.COURSE_CATALOG_LIST;
} }