Compare commits

..

22 Commits

Author SHA1 Message Date
doprz
7a4f40a765 feat(release): v2.2.0 2025-04-06 18:11:27 -05:00
doprz
d11d55db66 chore: update exec list (#580)
* chore: update exec list

* chore: update roles

* chore: update h2

* fix: key

* chore: caps
2025-04-05 14:33:44 -05:00
Samuel Gunter
76b6aa7c15 fix: include logo in screenshot, fix screenshots on small/zoomed windows (#579)
* fix: include logo in screenshot

* fix: screenshots on small/zoomed windows, screenshots with no async/other

---------

Co-authored-by: Razboy20 <razboy20@gmail.com>
2025-04-05 11:21:37 -05:00
Samuel Gunter
70d4fecad6 feat: recruitment banner for designer (#578) 2025-04-04 09:56:37 -05:00
Samuel Gunter
c3fa91752c chore: bump migration dialog message number, remove unused UpdateText component (#577) 2025-04-02 10:00:05 -05:00
Ethan Lanting
7c2beef193 feat: auto create empty schedule when deleted all schedules (#552)
* feat: enhance schedule deletion to create a new schedule if none remain

* feat: set active index to new schedule if only one exists

* chore: run lint

* feat: enhance schedule deletion to create a new schedule if none remain

* feat: set active index to new schedule if only one exists

* chore: run lint

* feat: reset schedules on update, refactor invariant to within deleteSchedule

* chore: pnpm lint

---------

Co-authored-by: Samuel Gunter <29130894+Samathingamajig@users.noreply.github.com>
Co-authored-by: Samuel Gunter <sgunter@utexas.edu>
2025-04-01 13:24:08 -05:00
Samuel Gunter
630d0d80d2 chore: professionalism in jokes (#572) 2025-03-26 14:16:06 -05:00
Samuel Gunter
695743104c feat: persist sidebar toggle state (#569) 2025-03-26 13:50:34 -05:00
Samuel Gunter
d014244b28 chore: pin volta pnpm version (#573) 2025-03-25 23:00:34 -05:00
Samuel Gunter
5cd56259f7 test: fix flaky sleep test (#571) 2025-03-24 02:28:41 -05:00
Samuel Gunter
fa9f78b46e feat: sticky calendar header and days (#568)
* feat: sticky calendar days

* feat: partial height borders for day labels

* feat: make calendar header actually sticky

* fix: remove unneeded gap

* refactor: add preston as co-author

Co-authored-by: Preston-Cook <preston.l.cook@gmail.com>

* fix: z-index issues with export sub-buttons

---------

Co-authored-by: Preston-Cook <preston.l.cook@gmail.com>
Co-authored-by: doprz <52579214+doprz@users.noreply.github.com>
2025-03-23 19:49:11 -05:00
Samuel Gunter
4a5f67f0fd fix: ics calendar export dates (#535)
* feat: academicCalendars object

* feat: seemingly working start, end, and until dates

* feat: seemingly working everything

* style: removed unnecessary deps, reorganized code

* style: code comments yay

* chore: old version of pnpm?

* ci: force github actions to rerun

* feat: list instructors in ics string, basic tests

* feat: testable code for ICS, tests for ICS, filter excluded dates

* style: eslint autofix

* test: check for graceful handling of errors in ICS

* fix: actually use scheduleToIcsString

* chore: eslint didn't include a space where it should've

* fix: ensure tz everywhere

* refactor: move string util to string util file

* feat: em dash in calendar event title

* feat: academic calendars 22-23 and 23-24

* fix: en dash instead of em dash
2025-03-22 22:55:16 -05:00
Yash Kukrecha
3bed9cc27f fix(schedule): truncate long schedule names in popup (#564) 2025-03-22 21:10:21 -05:00
ishita778
0dcae25b93 chore: removed extra space at calendar footer (#557)
* 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
2025-03-18 04:00:46 -05:00
beastgwert
ca734dcd39 feat: rework start time to checkboxes (#553)
* feat: replace dropdown with checkbox

* refactor: remove console logs

* refactor: eslint happy

* refactor: change daysValue from string to array

* style: match course schedule page styling

* style: remove label font-normal

* style: finalize course schedule page style match

---------

Co-authored-by: Samuel Gunter <29130894+Samathingamajig@users.noreply.github.com>
2025-03-15 22:37:51 -05:00
Samuel Gunter
9448072112 feat: ensure unique splash text on schedule change (#554) 2025-03-10 18:57:54 -05:00
ishita778
b1e98ca9d7 chore: Modify the schedule creation prompt (#550)
* chore: modify the schedule creation prompt

* chore: changed border color to offwhite

---------

Co-authored-by: Samuel Gunter <29130894+Samathingamajig@users.noreply.github.com>
2025-03-08 21:01:46 -06:00
Abdulrahman Alshahrani
f036d409e6 feat: implement a What's New prompt (#539)
* feat: create whats new initial component

* feat: create initial whats-new hook

* feat: create whats-new story

* feat: add button to open dialog in storybook

* feat: complete popup ui

* feat: add check for new updates or installs

* fix: fix linter issues

* fix: use proper features and add video

* fix: properly fetch version from manifest

* feat: add a link to open the popup

* fix: update spacing and features' content

* fix: update UTRP Map name

* fix: increase icon size and display version correctly

* feat: update the features video

* fix: update offwhite color

* fix: color typo

* fix: fixing colors again

* feat: use numbers instead of boolean

* fix: typo in import

* feat: add type safety to features array

* feat: cdn video url

* fix: delete mp4 video

* feat: handle video failure to load

* fix: make border outline tight to video

* feat: make design responsive

* fix: make features array readonly

---------

Co-authored-by: doprz <52579214+doprz@users.noreply.github.com>
Co-authored-by: Derek Chen <derex1987@gmail.com>
2025-03-08 15:41:09 -06:00
ishita778
5493c63f18 chore: Change the highlight color on-hover over grade distribution bars to off-white/50 (#547)
* chore: opacity changed to 50 percent

* chore: fix pnpm lock file

* chore: pnpm checks pass attempt

* chore: pnpm lock checks should work

* fix: opacity 50 and pnpm version

* chore: pnpm lock file fix
2025-03-07 13:31:23 -06:00
ishita778
6c3139bf0f fix: merge course labels across pages (#541)
* fix: merge course labels across pages

* fix: merge same course

* fix: all checks pass

* fix: updated addrows function

* fix: prettier check

* fix: all checks

* fix: all checks pass

* fix: moved query tbody outside

* fix: uses row element

* fix: checks pass now

---------

Co-authored-by: Samuel Gunter <29130894+Samathingamajig@users.noreply.github.com>
2025-03-05 15:08:33 -06:00
Krish Patel
28ebb69612 style: update delete schedule prompt (#546)
Co-authored-by: Samuel Gunter <29130894+Samathingamajig@users.noreply.github.com>
2025-03-05 14:15:59 -06:00
Samuel Gunter
008cb40cb8 style: move input click style to central place (#532)
Co-authored-by: doprz <52579214+doprz@users.noreply.github.com>
2025-03-05 14:05:34 -06:00
46 changed files with 1794 additions and 258 deletions

View File

@@ -1,3 +1,22 @@
## [2.2.0](https://github.com/Longhorn-Developers/UT-Registration-Plus/compare/v2.1.1...v2.2.0) (2025-04-06)
### Features
- auto create empty schedule when deleted all schedules ([#552](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/552)) ([7c2beef](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/7c2beef1930fbc887e8ec1aea789016b3150cd21))
- ensure unique splash text on schedule change ([#554](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/554)) ([9448072](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/94480721124e052426c1f3236e8605c7088df79c))
- implement a What's New prompt ([#539](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/539)) ([f036d40](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/f036d409e60a39fd1d3cb2f0db53a6056615f336))
- persist sidebar toggle state ([#569](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/569)) ([6957431](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/695743104c57951ba1957258c60c843f8fae793f))
- recruitment banner for designer ([#578](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/578)) ([70d4fec](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/70d4fecad61ec3cd3ba839de302fd851e075d073))
- rework start time to checkboxes ([#553](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/553)) ([ca734dc](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/ca734dcd39a433cfd2e930ea04adeba959b32c36))
- sticky calendar header and days ([#568](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/568)) ([fa9f78b](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/fa9f78b46e3a2270a44d4cc0691195a7c695cb93))
### Bug Fixes
- ics calendar export dates ([#535](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/535)) ([4a5f67f](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/4a5f67f0fda9f0ef57f821e4b7a55d63f099f579))
- include logo in screenshot, fix screenshots on small/zoomed windows ([#579](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/579)) ([76b6aa7](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/76b6aa7c150299dfcfa4b3dc00ce2de32f90f75c))
- merge course labels across pages ([#541](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/541)) ([6c3139b](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/6c3139bf0f324c9a7be826b6c24e8bf142fc53b1))
- **schedule:** truncate long schedule names in popup ([#564](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/564)) ([3bed9cc](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/3bed9cc27febfe795af0766a913c4845e74cc2da))
## [2.1.1](https://github.com/Longhorn-Developers/UT-Registration-Plus/compare/v2.1.0...v2.1.1) (2025-03-03)
### Features

View File

@@ -1,7 +1,7 @@
{
"name": "ut-registration-plus",
"displayName": "UT Registration Plus",
"version": "2.1.1",
"version": "2.2.0",
"description": "UT Registration Plus is a Chrome extension that allows students to easily register for classes.",
"private": true,
"homepage": "https://github.com/Longhorn-Developers/UT-Registration-Plus",
@@ -27,6 +27,7 @@
"prepare": "husky"
},
"dependencies": {
"@date-fns/tz": "^1.2.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
@@ -35,11 +36,13 @@
"@octokit/rest": "^21.1.1",
"@phosphor-icons/react": "^2.1.7",
"@sentry/react": "^8.55.0",
"@tanstack/react-query": "^5.69.0",
"@unocss/vite": "^0.63.6",
"@vitejs/plugin-react": "^4.3.4",
"chrome-extension-toolkit": "^0.0.54",
"clsx": "^2.1.1",
"conventional-changelog": "^6.0.0",
"date-fns": "^4.1.0",
"highcharts": "^11.4.8",
"highcharts-react-official": "^3.2.1",
"html-to-image": "^1.11.13",
@@ -157,6 +160,7 @@
}
},
"volta": {
"node": "20.9.0"
"node": "20.9.0",
"pnpm": "10.6.5"
}
}

34
pnpm-lock.yaml generated
View File

@@ -19,6 +19,9 @@ importers:
.:
dependencies:
'@date-fns/tz':
specifier: ^1.2.0
version: 1.2.0
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -43,6 +46,9 @@ importers:
'@sentry/react':
specifier: ^8.55.0
version: 8.55.0(react@18.3.1)
'@tanstack/react-query':
specifier: ^5.69.0
version: 5.69.0(react@18.3.1)
'@unocss/vite':
specifier: ^0.63.6
version: 0.63.6(patch_hash=9e2d2732a6e057a2ca90fba199730f252d8b4db8631b2c6ee0854fce7771bc95)(rollup@4.34.8)(typescript@5.7.3)(vite@5.4.14(@types/node@22.13.5)(sass@1.85.1)(terser@5.39.0))
@@ -58,6 +64,9 @@ importers:
conventional-changelog:
specifier: ^6.0.0
version: 6.0.0(conventional-commits-filter@5.0.0)
date-fns:
specifier: ^4.1.0
version: 4.1.0
highcharts:
specifier: ^11.4.8
version: 11.4.8
@@ -571,6 +580,9 @@ packages:
'@crxjs/vite-plugin@2.0.0-beta.21':
resolution: {integrity: sha512-kSXgHHqCXASqJ8NmY94+KLGVwdtkJ0E7KsRQ+vbMpRliJ5ze0xnSk0l41p4txlUysmEoqaeo4Xb7rEFdcU2zjQ==}
'@date-fns/tz@1.2.0':
resolution: {integrity: sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==}
'@dnd-kit/accessibility@3.1.1':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
@@ -1972,6 +1984,14 @@ packages:
'@swc/types@0.1.18':
resolution: {integrity: sha512-NZghLaQvF3eFdj2DUjGkpwaunbZYaRcxciHINnwA4n3FrLAI8hKFOBqs2wkcOiLQfWkIdfuG6gBkNFrkPNji5g==}
'@tanstack/query-core@5.69.0':
resolution: {integrity: sha512-Kn410jq6vs1P8Nm+ZsRj9H+U3C0kjuEkYLxbiCyn3MDEiYor1j2DGVULqAz62SLZtUZ/e9Xt6xMXiJ3NJ65WyQ==}
'@tanstack/react-query@5.69.0':
resolution: {integrity: sha512-Ift3IUNQqTcaFa1AiIQ7WCb/PPy8aexZdq9pZWLXhfLcLxH0+PZqJ2xFImxCpdDZrFRZhLJrh76geevS5xjRhA==}
peerDependencies:
react: ^18 || ^19
'@tanstack/react-virtual@3.13.2':
resolution: {integrity: sha512-LceSUgABBKF6HSsHK2ZqHzQ37IKV/jlaWbHm+NyTa3/WNb/JZVcThDuTainf+PixltOOcFCYXwxbLpOX9sCx+g==}
peerDependencies:
@@ -3269,6 +3289,9 @@ packages:
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
engines: {node: '>= 0.4'}
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
debug@2.6.9:
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
peerDependencies:
@@ -7365,6 +7388,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@date-fns/tz@1.2.0': {}
'@dnd-kit/accessibility@3.1.1(react@18.3.1)':
dependencies:
react: 18.3.1
@@ -8607,6 +8632,13 @@ snapshots:
dependencies:
'@swc/counter': 0.1.3
'@tanstack/query-core@5.69.0': {}
'@tanstack/react-query@5.69.0(react@18.3.1)':
dependencies:
'@tanstack/query-core': 5.69.0
react: 18.3.1
'@tanstack/react-virtual@3.13.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@tanstack/virtual-core': 3.13.2
@@ -10237,6 +10269,8 @@ snapshots:
es-errors: 1.3.0
is-data-view: 1.0.2
date-fns@4.1.0: {}
debug@2.6.9:
dependencies:
ms: 2.0.0

View File

@@ -34,10 +34,8 @@ const splashText: string[] = [
"The Block of Butter incident of '22",
'Begun, the midterms have.',
'You must construct additional schedules',
"Arrows of Christ vs Church of Scientology was the crossover we didn't know we needed",
'THE WALK SIGN IS ON TO CROSS GUADALUPE AND 21ST',
'Pay attention. Might learn something.',
'Long ago, apartment rates lived together in harmony. Then, everything changed when American Campus Communities Inc attacked.',
'Roll for Initiative!',
'The line at the on-campus Starbucks is longer than your course waitlist.',
'The weather changes more often than your class schedule.',
@@ -59,7 +57,7 @@ const splashText: string[] = [
'follow @sghsri!',
'Officially part of the SEC',
'Planner is now acquired by Plus',
'Longhorn-Developers is the best UT Student Org',
'Longhorn Developers is the best UT Student Org',
'The Eiffel Tower is the UT Tower of Paris',
'A pen and paper is old fashioned, but sometimes old ways are best',
'A heart is like bedrock, destroyable only by cheating',
@@ -69,13 +67,13 @@ const splashText: string[] = [
'Almost Turing complete',
'#BF5700',
'The waitlist is a lie!',
`He's a CS Major, but he showers regularly. 🧢`,
"He's a CS Major, but he showers regularly. 🧢",
'A CS major walks into a bar. The bar is empty because it is a CS major.',
'UT Registration Plus - The only thing that can make registration worse is not having it',
'UT Registration Plus - We make registration slightly less painful. Slightly.',
'UT Registration Plus - Do you really want to figure out which professors will ruin your GPA by yourself?',
'Ayo tf is a memory leak',
"lowkey we never thought we'd get this far, how tf are 60k of you people using this",
"Ayo what's is a memory leak",
"lowkey we never thought we'd get this far, how are 60k of you people using this",
"dang we're really out here making a splash",
"We'd make a joke about A&M, but we're not sure they can read",
"We've only caused one or two outages, we swear!",
@@ -113,7 +111,7 @@ const splashText: string[] = [
"Stop trying to make UTRP happen, it's not going to happen!",
'Befriend the raccoons on campus',
`It's ${new Date().toLocaleString('en-US', { month: 'long', day: 'numeric' })} and OU still sucks`,
'As seen on TV! ',
'As seen on TV!',
"Should you major in Compsci? well, here's a better question. do you wanna have a bad time?",
];

View File

@@ -1,4 +1,7 @@
import { ExtensionStore } from '@shared/storage/ExtensionStore';
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
import createSchedule from '../lib/createSchedule';
/**
* Called when the extension is updated (or when the extension is reloaded in development mode)
@@ -8,4 +11,11 @@ export default async function onUpdate() {
version: chrome.runtime.getManifest().version,
lastUpdate: Date.now(),
});
const schedules = await UserScheduleStore.get('schedules');
// By invariant, there must always be at least one schedule
if (schedules.length === 0) {
createSchedule('Schedule 1');
}
}

View File

@@ -36,5 +36,11 @@ export default async function createSchedule(scheduleName: string) {
schedules.push(newSchedule);
await UserScheduleStore.set('schedules', schedules);
// If there is only one schedule, set the active index to the new schedule
if (schedules.length <= 1) {
await UserScheduleStore.set('activeIndex', 0);
}
return newSchedule.id;
}

View File

@@ -22,6 +22,11 @@ export default async function deleteSchedule(scheduleId: string): Promise<string
schedules.splice(scheduleIndex, 1);
await UserScheduleStore.set('schedules', schedules);
// By invariant, there must always be at least one schedule
if (schedules.length === 0) {
createSchedule('Schedule 1');
}
let newActiveIndex = activeIndex;
if (scheduleIndex < activeIndex) {
newActiveIndex = activeIndex - 1;

View File

@@ -3,6 +3,7 @@ import Calendar from '@views/components/calendar/Calendar';
import DialogProvider from '@views/components/common/DialogProvider/DialogProvider';
import ExtensionRoot from '@views/components/common/ExtensionRoot/ExtensionRoot';
import { MigrationDialog } from '@views/components/common/MigrationDialog';
import { WhatsNewDialog } from '@views/components/common/WhatsNewPopup';
import SentryProvider from '@views/contexts/SentryContext';
import { MessageListener } from 'chrome-extension-toolkit';
import useKC_DABR_WASM from 'kc-dabr-wasm';
@@ -34,6 +35,7 @@ export default function CalendarMain() {
<ExtensionRoot className='h-full w-full'>
<DialogProvider>
<MigrationDialog />
<WhatsNewDialog />
<Calendar />
</DialogProvider>
</ExtensionRoot>

View File

@@ -1,5 +1,6 @@
import CourseCatalogMain from '@views/components/CourseCatalogMain';
import InjectedButton from '@views/components/injected/AddAllButton';
import DaysCheckbox from '@views/components/injected/DaysCheckbox';
import getSiteSupport, { SiteSupport } from '@views/lib/getSiteSupport';
import React from 'react';
import { createRoot } from 'react-dom/client';
@@ -25,3 +26,7 @@ if (support === SiteSupport.COURSE_CATALOG_DETAILS || support === SiteSupport.CO
if (support === SiteSupport.MY_UT) {
renderComponent(InjectedButton);
}
if (support === SiteSupport.COURSE_CATALOG_SEARCH) {
renderComponent(DaysCheckbox);
}

View File

@@ -8,11 +8,14 @@ interface IExtensionStore {
version: string;
/** When was the last update */
lastUpdate: number;
/** The last version of the "What's New" popup that was shown to the user */
lastWhatsNewPopupVersion: number;
}
export const ExtensionStore = createLocalStore<IExtensionStore>({
version: chrome.runtime.getManifest().version,
lastUpdate: Date.now(),
lastWhatsNewPopupVersion: 0,
});
debugStore({ ExtensionStore });

View File

@@ -18,6 +18,9 @@ export interface IOptionsStore {
/** whether we should open the calendar in a new tab; default is to focus an existing calendar tab */
alwaysOpenCalendarInNewTab: boolean;
/** whether the calendar sidebar should be shown when the calendar is opened */
showCalendarSidebar: boolean;
}
export const OptionsStore = createSyncStore<IOptionsStore>({
@@ -26,6 +29,7 @@ export const OptionsStore = createSyncStore<IOptionsStore>({
enableScrollToLoad: true,
enableDataRefreshing: false,
alwaysOpenCalendarInNewTab: false,
showCalendarSidebar: true,
});
/**
@@ -40,6 +44,7 @@ export const initSettings = async () =>
enableScrollToLoad: await OptionsStore.get('enableScrollToLoad'),
enableDataRefreshing: await OptionsStore.get('enableDataRefreshing'),
alwaysOpenCalendarInNewTab: await OptionsStore.get('alwaysOpenCalendarInNewTab'),
showCalendarSidebar: await OptionsStore.get('showCalendarSidebar'),
}) satisfies IOptionsStore;
// Clothing retailer right

View File

@@ -48,3 +48,22 @@ export const ellipsify = (input: string, chars: number): string => {
}
return ellipisifed;
};
/**
* Stringifies a list of items in English format.
*
* @param items - The list of items to stringify.
* @returns A string representation of the list in English format.
* @example
* englishStringifyList([]) // ''
* englishStringifyList(['Alice']) // 'Alice'
* englishStringifyList(['Alice', 'Bob']) // 'Alice and Bob'
* englishStringifyList(['Alice', 'Bob', 'Charlie']) // 'Alice, Bob, and Charlie'
*/
export const englishStringifyList = (items: readonly string[]): string => {
if (items.length === 0) return '';
if (items.length === 1) return items[0]!;
if (items.length === 2) return `${items[0]} and ${items[1]}`;
return `${items.slice(0, -1).join(', ')}, and ${items.at(-1)}`;
};

View File

@@ -1,4 +1,4 @@
import { capitalize, capitalizeFirstLetter, ellipsify } from '@shared/util/string';
import { capitalize, capitalizeFirstLetter, ellipsify, englishStringifyList } from '@shared/util/string';
import { describe, expect, it } from 'vitest';
// TODO: Fix `string.ts` and `string.test.ts` to make the tests pass
@@ -54,3 +54,49 @@ describe('ellipsify', () => {
expect(ellipsify('', 5)).toBe('');
});
});
describe('englishStringifyList', () => {
it('should handle an empty array', () => {
const data = [] satisfies string[];
const result = englishStringifyList(data);
const expected = '';
expect(result).toBe(expected);
});
it('should handle 1 element', () => {
const data = ['Alice'] satisfies string[];
const result = englishStringifyList(data);
const expected = 'Alice';
expect(result).toBe(expected);
});
it('should handle 2 elements', () => {
const data = ['Alice', 'Bob'] satisfies string[];
const result = englishStringifyList(data);
const expected = 'Alice and Bob';
expect(result).toBe(expected);
});
it('should handle 3 elements', () => {
const data = ['Alice', 'Bob', 'Charlie'] satisfies string[];
const result = englishStringifyList(data);
const expected = 'Alice, Bob, and Charlie';
expect(result).toBe(expected);
});
it('should handle n elements', () => {
const testcases = [
{ data: [], expected: '' },
{ data: ['foo'], expected: 'foo' },
{ data: ['foo', 'bar'], expected: 'foo and bar' },
{ data: ['foo', 'bar', 'baz'], expected: 'foo, bar, and baz' },
{ data: ['a', 'b', 'c', 'd'], expected: 'a, b, c, and d' },
{ data: 'abcdefghijk'.split(''), expected: 'a, b, c, d, e, f, g, h, i, j, and k' },
] satisfies { data: string[]; expected: string }[];
for (const { data, expected } of testcases) {
const result = englishStringifyList(data);
expect(result).toBe(expected);
}
});
});

View File

@@ -3,11 +3,13 @@ import { describe, expect, it } from 'vitest';
describe('sleep', () => {
it('should resolve after the specified number of milliseconds', async () => {
const start = Date.now();
const start = performance.now();
const milliseconds = 1000;
await sleep(milliseconds);
const end = Date.now();
const end = performance.now();
const elapsed = end - start;
expect(elapsed).toBeGreaterThanOrEqual(milliseconds);
// Flaky test due to JS's lack of precision in setTimeout,
// so we allow for a 1ms difference
expect(elapsed).toBeGreaterThanOrEqual(milliseconds - 1);
});
});

View File

@@ -1,32 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react';
import type { UpdateTextProps } from '@views/components/common/UpdateText';
import UpdateText from '@views/components/common/UpdateText';
import React from 'react';
const meta = {
title: 'Components/Common/UpdateText',
component: UpdateText,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
courses: { control: 'object' },
},
} satisfies Meta<typeof UpdateText>;
export default meta;
type Story = StoryObj<typeof meta>;
const Template = (args: React.JSX.IntrinsicAttributes & UpdateTextProps) => <UpdateText {...args} />;
export const Default: Story = {
render: Template,
args: {
courses: ['12345', '23456', '34567', '45678', '56789'],
},
};
Default.args = {
courses: ['12345', '23456', '34567', '45678', '56789'],
};

View File

@@ -0,0 +1,38 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from '@views/components/common/Button';
import DialogProvider from '@views/components/common/DialogProvider/DialogProvider';
import WhatsNewPopup from '@views/components/common/WhatsNewPopup';
import useWhatsNewPopUp from '@views/hooks/useWhatsNew';
import React from 'react';
const meta = {
title: 'Components/Common/WhatsNewPopup',
component: WhatsNewPopup,
parameters: {
// Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
layout: 'centered',
},
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
tags: ['autodocs'],
} satisfies Meta<typeof WhatsNewPopup>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
render: () => (
<DialogProvider>
<InnerComponent />
</DialogProvider>
),
};
const InnerComponent = () => {
const handleOnClick = useWhatsNewPopUp();
return (
<Button color='ut-burntorange' onClick={handleOnClick}>
Open Dialog
</Button>
);
};

View File

@@ -174,3 +174,137 @@ export const mikeScottCS314Schedule: UserSchedule = new UserSchedule({
hours: 3,
updatedAt: Date.now(),
});
export const multiMeetingMultiInstructorCourse: Course = new Course({
colors: {
primaryColor: '#ef4444',
secondaryColor: '#991b1b',
},
core: [],
courseName: '44-REPORTING TEXAS',
creditHours: 3,
department: 'J',
description: [
"Contemporary social, professional, and intellectual concerns with the practice of journalism. Students work as online reporters, photographers, and editors for the School of Journalism's Reporting Texas Web site.",
'Prerequisite: Graduate standing; additional prerequisites vary with the topic.',
'Designed to accommodate 35 or fewer students. Course number may be repeated for credit when the topics vary.',
],
flags: [],
fullName: 'J 395 44-REPORTING TEXAS',
instructionMode: 'In Person',
instructors: [
{
firstName: 'JOHN',
fullName: 'SCHWARTZ, JOHN R',
lastName: 'SCHWARTZ',
middleInitial: 'R',
},
{
firstName: 'JOHN',
fullName: 'BRIDGES, JOHN A III',
lastName: 'BRIDGES',
middleInitial: 'A',
},
],
isReserved: true,
number: '395',
schedule: {
meetings: [
{
days: ['Tuesday', 'Thursday'],
endTime: 660,
location: {
building: 'CMA',
room: '6.146',
},
startTime: 570,
},
{
days: ['Friday'],
endTime: 960,
location: {
building: 'DMC',
room: '3.208',
},
startTime: 780,
},
],
},
scrapedAt: 1742491957535,
semester: {
code: '20259',
season: 'Fall',
year: 2025,
},
status: 'OPEN',
uniqueId: 10335,
url: 'https://utdirect.utexas.edu/apps/registrar/course_schedule/20259/10335/',
});
export const multiMeetingMultiInstructorSchedule: UserSchedule = new UserSchedule({
courses: [multiMeetingMultiInstructorCourse],
id: 'mmmis',
name: 'Multi Meeting Multi Instructor Schedule',
hours: 3,
updatedAt: Date.now(),
});
export const chatterjeeCS429Course: Course = new Course({
colors: {
primaryColor: '#0284c7',
secondaryColor: '#0c4a6e',
},
core: [],
courseName: 'COMP ORGANIZATN AND ARCH',
creditHours: 4,
department: 'C S',
description: [
'Restricted to computer science majors.',
'An introduction to low-level computer design ranging from the basics of digital design to the hardware/software interface for application programs. Includes basic systems principles of pipelining and caching, and requires writing and understanding programs at multiple levels.',
'Computer Science 429 and 429H may not both be counted.',
'Prerequisite: The following courses with a grade of at least C-: Computer Science 311 or 311H; and Computer Science 314 or 314H.',
],
flags: [],
fullName: 'C S 429 COMP ORGANIZATN AND ARCH',
instructionMode: 'In Person',
instructors: [
{
firstName: 'SIDDHARTHA',
fullName: 'CHATTERJEE, SIDDHARTHA',
lastName: 'CHATTERJEE',
},
],
isReserved: true,
number: '429',
schedule: {
meetings: [
{
days: ['Monday', 'Tuesday', 'Wednesday', 'Thursday'],
endTime: 1020,
location: {
building: 'UTC',
room: '3.102',
},
startTime: 960,
},
{
days: ['Friday'],
endTime: 660,
location: {
building: 'GSB',
room: '2.122',
},
startTime: 540,
},
],
},
scrapedAt: 1742496630445,
semester: {
code: '20259',
season: 'Fall',
year: 2025,
},
status: 'OPEN',
uniqueId: 54795,
url: 'https://utdirect.utexas.edu/apps/registrar/course_schedule/20259/54795/',
});

View File

@@ -13,7 +13,7 @@ import { CourseCatalogScraper } from '@views/lib/CourseCatalogScraper';
import getCourseTableRows from '@views/lib/getCourseTableRows';
import type { SiteSupportType } from '@views/lib/getSiteSupport';
import { populateSearchInputs } from '@views/lib/populateSearchInputs';
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
interface Props {
support: Extract<SiteSupportType, 'COURSE_CATALOG_DETAILS' | 'COURSE_CATALOG_LIST'>;
@@ -27,6 +27,8 @@ export default function CourseCatalogMain({ support }: Props): JSX.Element | nul
const [selectedCourse, setSelectedCourse] = useState<Course | null>(null);
const [showPopup, setShowPopup] = useState(false);
const [enableScrollToLoad, setEnableScrollToLoad] = useState<boolean>(false);
const prevCourseTitleRef = useRef<string | null>(null);
const tbody = document.querySelector('table tbody')!;
useEffect(() => {
populateSearchInputs();
@@ -43,6 +45,9 @@ export default function CourseCatalogMain({ support }: Props): JSX.Element | nul
const ccs = new CourseCatalogScraper(support);
const scrapedRows = ccs.scrape(tableRows, true);
setRows(scrapedRows);
prevCourseTitleRef.current =
scrapedRows.findLast(row => row.course === null)?.element.querySelector('.course_header')?.textContent ??
null;
}, [support]);
useEffect(() => {
@@ -51,8 +56,17 @@ export default function CourseCatalogMain({ support }: Props): JSX.Element | nul
const addRows = (newRows: ScrapedRow[]) => {
newRows.forEach(row => {
document.querySelector('table tbody')!.appendChild(row.element);
const courseTitle = row.element.querySelector('.course_header')?.textContent ?? null;
if (row.course === null) {
if (courseTitle !== prevCourseTitleRef.current) {
tbody.appendChild(row.element);
prevCourseTitleRef.current = courseTitle;
}
} else {
tbody.appendChild(row.element);
}
});
setRows([...rows, ...newRows]);
};

View File

@@ -66,11 +66,19 @@ export default function PopupMain(): JSX.Element {
};
useEffect(() => {
const randomIndex = Math.floor(Math.random() * splashText.length);
setFunny(
splashText[randomIndex] ?? 'If you are seeing this, something has gone horribly wrong behind the scenes.'
setFunny(prevFunny => {
// Ensure that the next splash text is not the same as the previous one
const splashTextWithoutCurrent = splashText.filter(text => text !== prevFunny);
const randomIndex = Math.floor(Math.random() * splashTextWithoutCurrent.length);
return (
splashTextWithoutCurrent[randomIndex] ??
'If you are seeing this, something has gone horribly wrong behind the scenes.'
);
}, []);
});
// Generate a new splash text every time the active schedule changes
}, [activeSchedule.id]);
const handleOpenOptions = async () => {
const url = chrome.runtime.getURL('/options.html');

View File

@@ -1,8 +1,10 @@
import { Sidebar } from '@phosphor-icons/react';
import type { CalendarTabMessages } from '@shared/messages/CalendarMessages';
import { OptionsStore } from '@shared/storage/OptionsStore';
import type { Course } from '@shared/types/Course';
import { CRX_PAGES } from '@shared/types/CRXPages';
import { openReportWindow } from '@shared/util/openReportWindow';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import CalendarBottomBar from '@views/components/calendar/CalendarBottomBar';
import CalendarGrid from '@views/components/calendar/CalendarGrid';
import CalendarHeader from '@views/components/calendar/CalendarHeader/CalendarHeader';
@@ -13,8 +15,10 @@ import CourseCatalogInjectedPopup from '@views/components/injected/CourseCatalog
import { CalendarContext } from '@views/contexts/CalendarContext';
import useCourseFromUrl from '@views/hooks/useCourseFromUrl';
import { useFlattenedCourseSchedule } from '@views/hooks/useFlattenedCourseSchedule';
import useWhatsNewPopUp from '@views/hooks/useWhatsNew';
import { MessageListener } from 'chrome-extension-toolkit';
import clsx from 'clsx';
import type { ReactNode } from 'react';
import React, { useEffect, useState } from 'react';
import OutwardArrowIcon from '~icons/material-symbols/arrow-outward';
@@ -27,13 +31,32 @@ import CalendarFooter from './CalendarFooter';
/**
* Calendar page component
*/
export default function Calendar(): JSX.Element {
export default function Calendar(): ReactNode {
const { courseCells, activeSchedule } = useFlattenedCourseSchedule();
const asyncCourseCells = courseCells.filter(block => block.async);
const displayBottomBar = asyncCourseCells && asyncCourseCells.length > 0;
const [course, setCourse] = useState<Course | null>(useCourseFromUrl());
const [showPopup, setShowPopup] = useState<boolean>(course !== null);
const [showSidebar, setShowSidebar] = useState<boolean>(true);
const showWhatsNewDialog = useWhatsNewPopUp();
const queryClient = useQueryClient();
const { data: showSidebar, isPending: isSidebarStatePending } = useQuery({
queryKey: ['settings', 'showCalendarSidebar'],
queryFn: () => OptionsStore.get('showCalendarSidebar'),
staleTime: Infinity, // Prevent loading state on refocus
});
const { mutate: setShowSidebar } = useMutation({
mutationKey: ['settings', 'showCalendarSidebar'],
mutationFn: async (showSidebar: boolean) => {
OptionsStore.set('showCalendarSidebar', showSidebar);
},
onSuccess: (_, showSidebar) => {
queryClient.setQueryData(['settings', 'showCalendarSidebar'], showSidebar);
},
});
useEffect(() => {
const listener = new MessageListener<CalendarTabMessages>({
@@ -59,10 +82,12 @@ export default function Calendar(): JSX.Element {
if (course) setShowPopup(true);
}, [course]);
if (isSidebarStatePending) return null;
return (
<CalendarContext.Provider value>
<div className='h-full w-full flex flex-col'>
<div className='h-screen flex overflow-auto'>
<div className='h-screen flex overflow-auto screenshot:calendar-target'>
<div
className={clsx(
'py-spacing-6 relative h-full min-h-screen w-full flex flex-none flex-col justify-between overflow-clip whitespace-nowrap border-r border-ut-offwhite/50 shadow-[2px_0_10px,rgba(214_210_196_/_.1)] motion-safe:duration-300 motion-safe:ease-out-expo motion-safe:transition-[max-width] screenshot:hidden',
@@ -99,6 +124,7 @@ export default function Calendar(): JSX.Element {
<ResourceLinks />
<Divider orientation='horizontal' size='100%' />
{/* <TeamLinks /> */}
<div className='flex flex-col gap-spacing-3'>
<a
href={CRX_PAGES.REPORT}
className='flex items-center gap-spacing-2 text-ut-burntorange underline-offset-2 hover:underline'
@@ -112,6 +138,20 @@ export default function Calendar(): JSX.Element {
<Text variant='p'>Send us Feedback!</Text>
<OutwardArrowIcon className='h-4 w-4' />
</a>
<a
href=''
className='flex items-center gap-spacing-2 text-ut-burntorange underline-offset-2 hover:underline'
target='_blank'
rel='noreferrer'
onClick={event => {
event.preventDefault();
showWhatsNewDialog();
}}
>
<Text variant='p'>What&apos;s New!</Text>
<OutwardArrowIcon className='h-4 w-4' />
</a>
</div>
</div>
<CalendarFooter />
@@ -123,7 +163,7 @@ export default function Calendar(): JSX.Element {
// scrollbarGutter: 'stable',
}
}
className='h-full flex flex-grow flex-col overflow-x-scroll px-spacing-5'
className='z-1 h-full flex flex-grow flex-col overflow-x-scroll [&>*]:px-spacing-5'
>
<CalendarHeader
sidebarOpen={showSidebar}
@@ -131,7 +171,11 @@ export default function Calendar(): JSX.Element {
setShowSidebar(!showSidebar);
}}
/>
<div className='min-h-2xl min-w-5xl flex-grow overflow-auto pl-spacing-3 pt-spacing-3 screenshot:min-h-xl'>
<div
className={clsx('min-h-2xl min-w-5xl flex-grow gap-0 pl-spacing-3 screenshot:min-h-xl', {
'screenshot:flex-grow-0': displayBottomBar, // html-to-image seems to have a bug with flex-grow
})}
>
<CalendarGrid courseCells={courseCells} setCourse={setCourse} />
</div>
<CalendarBottomBar courseCells={courseCells} setCourse={setCourse} />

View File

@@ -3,6 +3,7 @@ import Text from '@views/components/common/Text/Text';
import { ColorPickerProvider } from '@views/contexts/ColorPickerContext';
import type { CalendarGridCourse } from '@views/hooks/useFlattenedCourseSchedule';
import clsx from 'clsx';
import type { ReactNode } from 'react';
import React from 'react';
import CalendarCourseBlock from './CalendarCourseCell';
@@ -18,19 +19,15 @@ type CalendarBottomBarProps = {
* @param courses - The list of courses to display in the calendar.
* @returns The rendered bottom bar component.
*/
export default function CalendarBottomBar({ courseCells, setCourse }: CalendarBottomBarProps): JSX.Element {
export default function CalendarBottomBar({ courseCells, setCourse }: CalendarBottomBarProps): ReactNode {
const asyncCourseCells = courseCells?.filter(block => block.async);
const displayCourses = asyncCourseCells && asyncCourseCells.length > 0;
if (!displayCourses) return null;
return (
<div className='w-full flex pl-spacing-7 pr-spacing-3 pt-spacing-4'>
<div
className={clsx('flex flex-grow items-center gap-1 text-nowrap', {
'py-7.5': !displayCourses,
})}
>
{displayCourses && (
<>
<div className='flex flex-grow items-center gap-1 text-nowrap'>
<Text variant='p' className='text-ut-black uppercase'>
Async / Other
</Text>
@@ -54,8 +51,6 @@ export default function CalendarBottomBar({ courseCells, setCourse }: CalendarBo
})}
</ColorPickerProvider>
</div>
</>
)}
</div>
</div>
);

View File

@@ -3,7 +3,7 @@ import CalendarCourseCell from '@views/components/calendar/CalendarCourseCell';
import Text from '@views/components/common/Text/Text';
import { ColorPickerProvider } from '@views/contexts/ColorPickerContext';
import type { CalendarGridCourse } from '@views/hooks/useFlattenedCourseSchedule';
import React from 'react';
import React, { Fragment } from 'react';
import CalendarCell from './CalendarGridCell';
@@ -30,13 +30,13 @@ function makeGridRow(row: number, cols: number): JSX.Element {
const hour = hoursOfDay[row]!;
return (
<>
<Fragment key={row}>
<CalendarHour hour={hour} />
<div className='grid-row-span-2 w-4 border-b border-r border-gray-300' />
{[...Array(cols).keys()].map(col => (
<CalendarCell key={`${row}${col}`} row={row} col={col} />
))}
</>
</Fragment>
);
}
@@ -56,23 +56,40 @@ export default function CalendarGrid({
setCourse,
}: React.PropsWithChildren<Props>): JSX.Element {
return (
<div className='grid grid-cols-[auto_auto_repeat(5,1fr)] grid-rows-[auto_repeat(26,1fr)] h-full'>
<div className='grid grid-cols-[auto_auto_repeat(5,1fr)] grid-rows-[auto_auto_repeat(27,1fr)] h-full'>
{/* Cover top left corner of grid, so time gets cut off at the top of the partial border */}
<div className='sticky top-[85px] z-10 col-span-2 h-3 bg-white' />
{/* Displaying day labels */}
<div />
<div className='w-4 border-b border-r border-gray-300' />
{daysOfWeek.map(day => (
<div className='h-4 flex items-end justify-center border-b border-r border-gray-300 pb-1.5'>
<Text key={day} variant='small' className='text-center text-ut-burntorange' as='div'>
<div
// Full height with background to prevent grid lines from showing behind
className='sticky top-[85px] z-10 row-span-2 h-7 flex flex-col items-end self-start justify-end bg-white'
key={day}
>
{/* Partial border height because that's what Isaiah wants */}
<div className='h-4 w-full flex items-end border-b border-r border-gray-300'>
{/* Alignment for text */}
<div className='h-[calc(1.75rem_-_1px)] w-full flex items-center justify-center'>
<Text variant='small' className='text-center text-ut-burntorange' as='div'>
{day}
</Text>
</div>
</div>
</div>
))}
{/* empty slot, for alignment */}
<div />
{/* time tick for the first hour */}
<div className='h-4 w-4 self-end border-b border-r border-gray-300' />
{[...Array(13).keys()].map(i => makeGridRow(i, 5))}
<CalendarHour hour={21} />
{Array(6)
.fill(1)
.map(() => (
<div className='h-4 flex items-end justify-center border-r border-gray-300' />
.map((_, i) => (
// Key suppresses warning about duplicate keys,
// and index is fine because it doesn't change between renders
// eslint-disable-next-line react/no-array-index-key
<div key={i} className='h-4 flex items-end justify-center border-r border-gray-300' />
))}
<ColorPickerProvider>
{courseCells && <AccountForCourseConflicts courseCells={courseCells} setCourse={setCourse} />}

View File

@@ -17,8 +17,8 @@ function CalendarCell({ row, col }: Props): JSX.Element {
<div
className='h-full w-full flex items-center border-b border-r border-gray-300'
style={{
gridColumn: col + 3,
gridRow: `${2 * row + 2} / ${2 * row + 4}`,
gridColumn: col + 3, // start in the 3rd 1-index column
gridRow: `${2 * row + 3} / ${2 * row + 5}`, // Span 2 rows, skip 2 header rows
}}
>
<div className='h-0 w-full border-t border-gray-300/25' />

View File

@@ -5,6 +5,7 @@ import { Button } from '@views/components/common/Button';
import DialogProvider from '@views/components/common/DialogProvider/DialogProvider';
import Divider from '@views/components/common/Divider';
import { ExtensionRootWrapper, styleResetClass } from '@views/components/common/ExtensionRoot/ExtensionRoot';
import { LargeLogo } from '@views/components/common/LogoIcon';
import ScheduleTotalHoursAndCourses from '@views/components/common/ScheduleTotalHoursAndCourses';
import useSchedules from '@views/hooks/useSchedules';
import clsx from 'clsx';
@@ -27,7 +28,7 @@ export default function CalendarHeader({ sidebarOpen, onSidebarToggle }: Calenda
return (
<div
style={{ scrollbarGutter: 'stable' }}
className='sticky left-0 right-0 min-h-[85px] flex items-center gap-5 overflow-x-scroll overflow-y-hidden pl-spacing-7 pt-spacing-5'
className='sticky left-0 right-0 top-0 z-10 min-h-[85px] flex items-center gap-5 overflow-x-scroll overflow-y-hidden bg-white pl-spacing-7 pt-spacing-5'
>
{!sidebarOpen && (
<Button
@@ -39,6 +40,9 @@ export default function CalendarHeader({ sidebarOpen, onSidebarToggle }: Calenda
/>
)}
<LargeLogo className='hidden! screenshot:flex!' />
<Divider className='self-center hidden! screenshot:block!' size='2.5rem' orientation='vertical' />
<div className='min-w-[11.5rem] screenshot:transform-origin-left screenshot:scale-120'>
<ScheduleTotalHoursAndCourses
scheduleName={activeSchedule.name}
@@ -62,7 +66,7 @@ export default function CalendarHeader({ sidebarOpen, onSidebarToggle }: Calenda
className={clsx([
styleResetClass,
'mt-spacing-3',
'min-w-max cursor-pointer origin-top-right rounded bg-white p-1 text-black shadow-lg transition border border-ut-offwhite/50 focus:outline-none',
'min-w-max cursor-pointer origin-top-right rounded bg-white p-1 text-black shadow-lg transition border border-ut-offwhite/50 focus:outline-none z-20',
'data-[closed]:(opacity-0 scale-95)',
'data-[enter]:(ease-out-expo duration-150)',
'data-[leave]:(ease-out duration-50)',

View File

@@ -0,0 +1,200 @@
type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
type Year = `20${Digit}${Digit}`;
type Month = `0${Exclude<Digit, 0>}` | `1${'0' | '1' | '2'}`;
type Day = `0${Exclude<Digit, 0>}` | `${1 | 2}${Digit}` | '30' | '31';
type DateStr = `${Year}-${Month}-${Day}`;
type SemesterDigit = 2 | 6 | 9;
type SemesterIdentifier = `20${Digit}${Digit}${SemesterDigit}`;
type AcademicCalendarSemester = {
year: number;
semester: 'Fall' | 'Spring' | 'Summer';
firstClassDate: DateStr;
lastClassDate: DateStr;
breakDates: (DateStr | [DateStr, DateStr])[];
};
/**
* UT Austin's academic calendars, split by semester.
*
* See https://registrar.utexas.edu/calendars for future years.
*/
export const academicCalendars = {
'20229': {
year: 2022,
semester: 'Fall',
firstClassDate: '2022-08-22',
lastClassDate: '2022-12-05',
breakDates: [
'2022-09-05', // Labor Day holiday
['2022-11-21', '2022-11-26'], // Fall break / Thanksgiving
],
},
'20232': {
year: 2023,
semester: 'Spring',
firstClassDate: '2023-01-09',
lastClassDate: '2023-04-24',
breakDates: [
'2023-01-16', // Martin Luther King, Jr. Day
['2023-03-13', '2023-03-18'], // Spring Break
],
},
'20236': {
year: 2023,
semester: 'Summer',
firstClassDate: '2023-06-01',
lastClassDate: '2023-08-11',
breakDates: [
'2023-06-19', // Juneteenth holiday
'2023-07-04', // Independence Day holiday
],
},
'20239': {
year: 2023,
semester: 'Fall',
firstClassDate: '2023-08-21',
lastClassDate: '2023-12-04',
breakDates: [
'2023-09-04', // Labor Day holiday
['2023-11-20', '2023-11-25'], // Fall break / Thanksgiving
],
},
'20242': {
year: 2024,
semester: 'Spring',
firstClassDate: '2024-01-16',
lastClassDate: '2024-04-29',
breakDates: [
'2024-01-15', // Martin Luther King, Jr. Day
['2024-03-11', '2024-03-16'], // Spring Break
],
},
'20246': {
year: 2024,
semester: 'Summer',
firstClassDate: '2024-06-06',
lastClassDate: '2024-08-16',
breakDates: [
'2024-06-19', // Juneteenth holiday
'2024-07-04', // Independence Day holiday
],
},
'20249': {
year: 2024,
semester: 'Fall',
firstClassDate: '2024-08-26',
lastClassDate: '2024-12-09',
breakDates: [
'2024-09-02', // Labor Day holiday
['2024-11-25', '2024-11-30'], // Fall break / Thanksgiving
],
},
'20252': {
year: 2025,
semester: 'Spring',
firstClassDate: '2025-01-13',
lastClassDate: '2025-04-28',
breakDates: [
'2025-01-20', // Martin Luther King, Jr. Day
['2025-03-17', '2025-03-22'], // Spring Break
],
},
'20256': {
year: 2025,
semester: 'Summer',
firstClassDate: '2025-06-05',
lastClassDate: '2025-08-15',
breakDates: [
'2025-06-19', // Juneteenth holiday
'2025-07-04', // Independence Day holiday
],
},
'20259': {
year: 2025,
semester: 'Fall',
firstClassDate: '2025-08-25',
lastClassDate: '2025-12-08',
breakDates: [
'2025-09-01', // Labor Day holiday
['2025-11-24', '2025-11-29'], // Fall break / Thanksgiving
],
},
'20262': {
year: 2026,
semester: 'Spring',
firstClassDate: '2026-01-12',
lastClassDate: '2026-04-27',
breakDates: [
'2026-01-19', // Martin Luther King, Jr. Day
['2026-03-16', '2026-03-21'], // Spring Break
],
},
'20266': {
year: 2026,
semester: 'Summer',
firstClassDate: '2026-06-04',
lastClassDate: '2026-08-14',
breakDates: [
'2026-06-19', // Juneteenth holiday
'2026-07-04', // Independence Day holiday
],
},
'20269': {
year: 2026,
semester: 'Fall',
firstClassDate: '2026-08-24',
lastClassDate: '2026-12-07',
breakDates: [
'2026-09-07', // Labor Day holiday
['2026-11-23', '2026-11-28'], // Fall break / Thanksgiving
],
},
'20272': {
year: 2027,
semester: 'Spring',
firstClassDate: '2027-01-11',
lastClassDate: '2027-04-26',
breakDates: [
'2027-01-18', // Martin Luther King, Jr. Day
['2027-03-15', '2027-03-20'], // Spring Break
],
},
'20276': {
year: 2027,
semester: 'Summer',
firstClassDate: '2027-06-03',
lastClassDate: '2027-08-13',
breakDates: [
'2027-07-04', // Independence Day holiday
],
},
'20279': {
year: 2027,
semester: 'Fall',
firstClassDate: '2027-08-23',
lastClassDate: '2027-12-06',
breakDates: [
'2027-09-06', // Labor Day holiday
['2027-11-22', '2027-11-27'], // Fall break / Thanksgiving
],
},
'20282': {
year: 2028,
semester: 'Spring',
firstClassDate: '2028-01-18',
lastClassDate: '2028-05-01',
breakDates: [
['2028-03-13', '2028-03-18'], // Spring Break
],
},
'20286': {
year: 2028,
semester: 'Summer',
firstClassDate: '2028-06-08',
lastClassDate: '2028-08-18',
breakDates: [
'2028-07-04', // Independence Day holiday
],
},
} as const satisfies Partial<Record<SemesterIdentifier, AcademicCalendarSemester>>;

View File

@@ -1,6 +1,31 @@
import { describe, expect, it } from 'vitest';
import { tz } from '@date-fns/tz';
import { Course } from '@shared/types/Course';
import { UserSchedule } from '@shared/types/UserSchedule';
import type { Serialized } from 'chrome-extension-toolkit';
import { format as formatDate, parseISO } from 'date-fns';
import {
chatterjeeCS429Course,
multiMeetingMultiInstructorCourse,
multiMeetingMultiInstructorSchedule,
} from 'src/stories/injected/mocked';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { formatToHHMMSS } from './utils';
import { allDatesInRanges, formatToHHMMSS, meetingToIcsString, nextDayInclusive, scheduleToIcsString } from './utils';
// Do all timezone calculations relative to UT's timezone
const TIMEZONE = 'America/Chicago';
const TZ = tz(TIMEZONE);
// Date and datetime formats used by iCal
const ISO_DATE_FORMAT = 'yyyy-MM-dd';
const ISO_BASIC_DATETIME_FORMAT = "yyyyMMdd'T'HHmmss";
/**
* Simulate serialized class instance, without the class's methods
*
* serde &lt;-- Serialize, Deserialize
*/
const serde = <T>(data: T) => JSON.parse(JSON.stringify(data)) as Serialized<T>;
describe('formatToHHMMSS', () => {
it('should format minutes to HHMMSS format', () => {
@@ -24,3 +49,431 @@ describe('formatToHHMMSS', () => {
expect(result).toBe(expected);
});
});
describe('nextDayInclusive', () => {
it('should return the same date if the given day is the same as the target day', () => {
const date = parseISO('2024-01-01', { in: TZ }); // Monday
const day = 1; // Monday
const result = nextDayInclusive(date, day);
expect(formatDate(result, ISO_DATE_FORMAT)).toBe('2024-01-01');
});
it('should return the next day if the given day is not the same as the target day', () => {
const date = parseISO('2024-07-18', { in: TZ }); // Thursday
const day = 2; // Tuesday
const result = nextDayInclusive(date, day);
expect(formatDate(result, ISO_DATE_FORMAT)).toBe('2024-07-23');
});
it('should wrap around years', () => {
const date = parseISO('2025-12-28', { in: TZ }); // Sunday
const day = 5; // Friday
const result = nextDayInclusive(date, day);
expect(formatDate(result, ISO_DATE_FORMAT)).toBe('2026-01-02');
});
it('should handle leap day', () => {
const date = parseISO('2024-02-27', { in: TZ }); // Tuesday
const day = 4; // Thursday
const result = nextDayInclusive(date, day);
expect(formatDate(result, ISO_DATE_FORMAT)).toBe('2024-02-29');
});
it('should handle an entire week of inputs', () => {
const date = parseISO('2024-08-20', { in: TZ }); // Tuesday
const days = [0, 1, 2, 3, 4, 5, 6] as const;
const results = days.map(day => nextDayInclusive(date, day));
const resultsFormatted = results.map(result => formatDate(result, ISO_DATE_FORMAT));
const expectedResults = [
'2024-08-25',
'2024-08-26',
'2024-08-20', // Same date
'2024-08-21',
'2024-08-22',
'2024-08-23',
'2024-08-24',
];
for (let i = 0; i < days.length; i++) {
expect(resultsFormatted[i]).toBe(expectedResults[i]);
}
});
it('should maintain hours/minutes/seconds', () => {
const date = parseISO('20250115T143021', { in: TZ }); // Wednesday
const days = [0, 1, 2, 3, 4, 5, 6] as const;
const results = days.map(day => nextDayInclusive(date, day));
const resultsFormatted = results.map(result => formatDate(result, ISO_BASIC_DATETIME_FORMAT));
const expectedResults = [
'20250119T143021',
'20250120T143021',
'20250121T143021',
'20250115T143021',
'20250116T143021',
'20250117T143021',
'20250118T143021',
];
for (let i = 0; i < days.length; i++) {
expect(resultsFormatted[i]).toBe(expectedResults[i]);
}
});
});
describe('allDatesInRanges', () => {
it('should handle empty array', () => {
const dateRanges = [] satisfies string[];
const result = allDatesInRanges(dateRanges);
const expected = [] satisfies Date[];
expect(result).toEqual(expected);
});
it('should handle a single date', () => {
const dateRanges = ['2025-03-14'] satisfies (string | [string, string])[];
const result = allDatesInRanges(dateRanges);
const expected = ['2025-03-14'].map(dateStr => parseISO(dateStr, { in: TZ })) satisfies Date[];
expect(result).toEqual(expected);
});
it('should handle a single date', () => {
const dateRanges = ['2025-03-14'] satisfies string[];
const result = allDatesInRanges(dateRanges);
const expected = ['2025-03-14'].map(dateStr => parseISO(dateStr, { in: TZ })) satisfies Date[];
expect(result).toEqual(expected);
});
it('should handle a single date range', () => {
const dateRanges = [['2025-03-14', '2025-03-19']] satisfies (string | [string, string])[];
const result = allDatesInRanges(dateRanges);
const expected = ['2025-03-14', '2025-03-15', '2025-03-16', '2025-03-17', '2025-03-18', '2025-03-19'].map(
dateStr => parseISO(dateStr, { in: TZ })
) satisfies Date[];
expect(result).toEqual(expected);
});
it('should handle multiple dates/date ranges', () => {
const dateRanges = [
'2025-02-14',
['2025-03-14', '2025-03-19'],
'2026-12-01',
['2026-12-03', '2026-12-05'],
] satisfies (string | [string, string])[];
const result = allDatesInRanges(dateRanges);
const expected = [
'2025-02-14', // '2025-02-14'
'2025-03-14', // ['2025-03-14', '2025-03-19']
'2025-03-15',
'2025-03-16',
'2025-03-17',
'2025-03-18',
'2025-03-19',
'2026-12-01', // '2026-12-01'
'2026-12-03', // ['2026-12-03', '2026-12-05'
'2026-12-04',
'2026-12-05',
].map(dateStr => parseISO(dateStr, { in: TZ })) satisfies Date[];
expect(result).toEqual(expected);
});
it('should handle month-/year-spanning ranges', () => {
const dateRanges = [
['2023-02-27', '2023-03-02'],
['2023-12-27', '2024-01-03'],
] satisfies (string | [string, string])[];
const result = allDatesInRanges(dateRanges);
const expected = [
'2023-02-27', // ['2023-02-27', '2023-03-2']
'2023-02-28',
'2023-03-01',
'2023-03-02',
'2023-12-27', // ['2023-12-27', '2024-01-3']
'2023-12-28',
'2023-12-29',
'2023-12-30',
'2023-12-31',
'2024-01-01',
'2024-01-02',
'2024-01-03',
].map(dateStr => parseISO(dateStr, { in: TZ })) satisfies Date[];
expect(result).toEqual(expected);
});
it('should handle leap years', () => {
const dateRanges = [
['2023-02-27', '2023-03-02'],
['2024-02-27', '2024-03-02'],
['2025-02-27', '2025-03-02'],
] satisfies (string | [string, string])[];
const result = allDatesInRanges(dateRanges);
const expected = [
'2023-02-27', // ['2023-02-27', '2023-03-2']
'2023-02-28',
'2023-03-01',
'2023-03-02',
'2024-02-27', // ['2024-02-27', '2024-03-2']
'2024-02-28',
'2024-02-29',
'2024-03-01',
'2024-03-02',
'2025-02-27', // ['2025-02-27', '2025-03-2']
'2025-02-28',
'2025-03-01',
'2025-03-02',
].map(dateStr => parseISO(dateStr, { in: TZ })) satisfies Date[];
expect(result).toEqual(expected);
});
});
describe('meetingToIcsString', () => {
it('should handle a one-day meeting with one instructor', () => {
const course = serde(multiMeetingMultiInstructorCourse);
course.instructors = course.instructors.slice(0, 1);
const meeting = course.schedule.meetings[1]!;
const result = meetingToIcsString(course, meeting);
const expected = (
`BEGIN:VEVENT
DTSTART;TZID=America/Chicago:20250829T130000
DTEND;TZID=America/Chicago:20250829T160000
RRULE:FREQ=WEEKLY;BYDAY=FR;UNTIL=20251209T060000Z
EXDATE;TZID=America/Chicago:20251128T130000
` +
// Only skips one Thanksgiving break day
`SUMMARY:J 395 44-REPORTING TEXAS
LOCATION:DMC 3.208
DESCRIPTION:Unique number: 10335\\nTaught by John Schwartz
END:VEVENT`
).replaceAll(/^\s+/gm, '');
expect(result).toBe(expected);
});
it('should handle unique numbers below 5 digits', () => {
const course = serde(multiMeetingMultiInstructorCourse);
course.instructors = course.instructors.slice(0, 1);
course.uniqueId = 4269;
const meeting = course.schedule.meetings[1]!;
const result = meetingToIcsString(course, meeting);
const expected = `BEGIN:VEVENT
DTSTART;TZID=America/Chicago:20250829T130000
DTEND;TZID=America/Chicago:20250829T160000
RRULE:FREQ=WEEKLY;BYDAY=FR;UNTIL=20251209T060000Z
EXDATE;TZID=America/Chicago:20251128T130000
SUMMARY:J 395 44-REPORTING TEXAS
LOCATION:DMC 3.208
DESCRIPTION:Unique number: 04269\\nTaught by John Schwartz
END:VEVENT`.replaceAll(/^\s+/gm, '');
expect(result).toBe(expected);
});
it('should handle a one-day meeting with multiple instructors', () => {
const course = serde(multiMeetingMultiInstructorCourse);
const meeting = course.schedule.meetings[1]!;
const result = meetingToIcsString(course, meeting);
const expected = `BEGIN:VEVENT
DTSTART;TZID=America/Chicago:20250829T130000
DTEND;TZID=America/Chicago:20250829T160000
RRULE:FREQ=WEEKLY;BYDAY=FR;UNTIL=20251209T060000Z
EXDATE;TZID=America/Chicago:20251128T130000
SUMMARY:J 395 44-REPORTING TEXAS
LOCATION:DMC 3.208
DESCRIPTION:Unique number: 10335\\nTaught by John Schwartz and John Bridges
END:VEVENT`.replaceAll(/^\s+/gm, '');
expect(result).toBe(expected);
});
it('should gracefully error on an out of range semester code', () => {
const course = serde(multiMeetingMultiInstructorCourse);
const meeting = course.schedule.meetings[0]!;
vi.spyOn(console, 'error').mockReturnValue(undefined);
course.semester = {
season: 'Fall',
year: 2010,
code: '20109',
};
const result = meetingToIcsString(course, meeting);
expect(result).toBeNull();
expect(console.error).toBeCalledWith(
`No academic calendar found for semester code: 20109; course uniqueId: ${course.uniqueId}`
);
});
it('should handle a multi-day meeting with multiple instructors', () => {
const course = serde(multiMeetingMultiInstructorCourse);
const meeting = course.schedule.meetings[0]!;
const result = meetingToIcsString(course, meeting);
const expected = `BEGIN:VEVENT
DTSTART;TZID=America/Chicago:20250826T093000
DTEND;TZID=America/Chicago:20250826T110000
RRULE:FREQ=WEEKLY;BYDAY=TU,TH;UNTIL=20251209T060000Z
EXDATE;TZID=America/Chicago:20251125T093000,20251127T093000
SUMMARY:J 395 44-REPORTING TEXAS
LOCATION:CMA 6.146
DESCRIPTION:Unique number: 10335\\nTaught by John Schwartz and John Bridges
END:VEVENT`.replaceAll(/^\s+/gm, '');
expect(result).toBe(expected);
});
afterEach(() => {
vi.restoreAllMocks();
});
});
describe('scheduleToIcsString', () => {
it('should handle an empty schedule', () => {
const schedule = serde(
new UserSchedule({
courses: [],
hours: 0,
id: 'fajowe',
name: 'fajowe',
updatedAt: Date.now(),
})
);
const result = scheduleToIcsString(schedule);
const expected = `BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
X-WR-CALNAME:My Schedule
END:VCALENDAR`.replaceAll(/^\s+/gm, '');
expect(result).toBe(expected);
});
it('should handle a schedule with courses but no meetings', () => {
const schedule = serde(
new UserSchedule({
courses: [
new Course({
...multiMeetingMultiInstructorCourse,
schedule: {
meetings: [],
},
}),
],
hours: 0,
id: 'fajowe',
name: 'fajowe',
updatedAt: Date.now(),
})
);
const result = scheduleToIcsString(schedule);
const expected = `BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
X-WR-CALNAME:My Schedule
END:VCALENDAR`.replaceAll(/^\s+/gm, '');
expect(result).toBe(expected);
});
it('should handle a schedule with courses but out-of-range semester', () => {
vi.spyOn(console, 'error').mockReturnValue(undefined);
const schedule = serde(
new UserSchedule({
courses: [
new Course({
...multiMeetingMultiInstructorCourse,
semester: {
season: 'Fall',
year: 2010,
code: '20109',
},
}),
],
hours: 0,
id: 'fajowe',
name: 'fajowe',
updatedAt: Date.now(),
})
);
const result = scheduleToIcsString(schedule);
const expected = `BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
X-WR-CALNAME:My Schedule
END:VCALENDAR`.replaceAll(/^\s+/gm, '');
expect(result).toBe(expected);
});
it('should handle a single course with multiple meetings', () => {
const schedule = serde(multiMeetingMultiInstructorSchedule);
const result = scheduleToIcsString(schedule);
const expected = `BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
X-WR-CALNAME:My Schedule
BEGIN:VEVENT
DTSTART;TZID=America/Chicago:20250826T093000
DTEND;TZID=America/Chicago:20250826T110000
RRULE:FREQ=WEEKLY;BYDAY=TU,TH;UNTIL=20251209T060000Z
EXDATE;TZID=America/Chicago:20251125T093000,20251127T093000
SUMMARY:J 395 44-REPORTING TEXAS
LOCATION:CMA 6.146
DESCRIPTION:Unique number: 10335\\nTaught by John Schwartz and John Bridges
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=America/Chicago:20250829T130000
DTEND;TZID=America/Chicago:20250829T160000
RRULE:FREQ=WEEKLY;BYDAY=FR;UNTIL=20251209T060000Z
EXDATE;TZID=America/Chicago:20251128T130000
SUMMARY:J 395 44-REPORTING TEXAS
LOCATION:DMC 3.208
DESCRIPTION:Unique number: 10335\\nTaught by John Schwartz and John Bridges
END:VEVENT
END:VCALENDAR`.replaceAll(/^\s+/gm, '');
expect(result).toBe(expected);
});
it('should handle a complex schedule', () => {
const schedule = serde(multiMeetingMultiInstructorSchedule);
schedule.courses.push(chatterjeeCS429Course);
const result = scheduleToIcsString(schedule);
const expected = (
`BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
X-WR-CALNAME:My Schedule
BEGIN:VEVENT
DTSTART;TZID=America/Chicago:20250826T093000
DTEND;TZID=America/Chicago:20250826T110000
RRULE:FREQ=WEEKLY;BYDAY=TU,TH;UNTIL=20251209T060000Z
EXDATE;TZID=America/Chicago:20251125T093000,20251127T093000
SUMMARY:J 395 44-REPORTING TEXAS
LOCATION:CMA 6.146
DESCRIPTION:Unique number: 10335\\nTaught by John Schwartz and John Bridges
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=America/Chicago:20250829T130000
DTEND;TZID=America/Chicago:20250829T160000
RRULE:FREQ=WEEKLY;BYDAY=FR;UNTIL=20251209T060000Z
EXDATE;TZID=America/Chicago:20251128T130000
SUMMARY:J 395 44-REPORTING TEXAS
LOCATION:DMC 3.208
DESCRIPTION:Unique number: 10335\\nTaught by John Schwartz and John Bridges
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=America/Chicago:20250825T160000
DTEND;TZID=America/Chicago:20250825T170000
RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH;UNTIL=20251209T060000Z
` +
// Skips Labor Day and only relevant days of Thanksgiving
`EXDATE;TZID=America/Chicago:20250901T160000,20251124T160000,20251125T160000,20251126T160000,20251127T160000
SUMMARY:C S 429 COMP ORGANIZATN AND ARCH
LOCATION:UTC 3.102
DESCRIPTION:Unique number: 54795\\nTaught by Siddhartha Chatterjee
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=America/Chicago:20250829T090000
DTEND;TZID=America/Chicago:20250829T110000
RRULE:FREQ=WEEKLY;BYDAY=FR;UNTIL=20251209T060000Z
EXDATE;TZID=America/Chicago:20251128T090000
SUMMARY:C S 429 COMP ORGANIZATN AND ARCH
LOCATION:GSB 2.122
DESCRIPTION:Unique number: 54795\\nTaught by Siddhartha Chatterjee
END:VEVENT
END:VCALENDAR`
).replaceAll(/^\s+/gm, '');
expect(result).toBe(expected);
});
afterEach(() => {
vi.restoreAllMocks();
});
});

View File

@@ -1,9 +1,36 @@
import { tz, TZDate } from '@date-fns/tz';
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
import type { Course } from '@shared/types/Course';
import type { CourseMeeting } from '@shared/types/CourseMeeting';
import Instructor from '@shared/types/Instructor';
import type { UserSchedule } from '@shared/types/UserSchedule';
import { downloadBlob } from '@shared/util/downloadBlob';
import { englishStringifyList } from '@shared/util/string';
import type { Serialized } from 'chrome-extension-toolkit';
import type { DateArg, Day } from 'date-fns';
import {
addDays,
eachDayOfInterval,
format as formatDate,
formatISO,
getDay,
nextDay,
parseISO,
set as setMultiple,
} from 'date-fns';
import { toBlob } from 'html-to-image';
import { academicCalendars } from './academic-calendars';
// Do all timezone calculations relative to UT's timezone
const TIMEZONE_ID = 'America/Chicago';
const TZ = tz(TIMEZONE_ID);
// Datetime format used by iCal, not directly supported by date-fns
// (date-fns adds the timezone to the end, but iCal doesn't want it)
const ISO_BASIC_DATETIME_FORMAT = "yyyyMMdd'T'HHmmss";
// iCal uses two-letter codes for days of the week
export const CAL_MAP = {
Sunday: 'SU',
Monday: 'MO',
@@ -14,6 +41,17 @@ export const CAL_MAP = {
Saturday: 'SA',
} as const satisfies Record<string, string>;
// Date objects' day field goes by index like this
const DAY_NAME_TO_NUMBER = {
Sunday: 0,
Monday: 1,
Tuesday: 2,
Wednesday: 3,
Thursday: 4,
Friday: 5,
Saturday: 6,
} as const satisfies Record<string, number>;
/**
* Retrieves the schedule from the UserScheduleStore based on the active index.
* @returns A promise that resolves to the retrieved schedule.
@@ -38,50 +76,186 @@ export const formatToHHMMSS = (minutes: number) => {
return `${hours}${mins}00`;
};
/**
* Formats a date in the format YYYYMMDD'T'HHmmss, which is the format used by iCal.
*
* @param date - The date to format.
* @returns The formatted date string.
*/
const iCalDateFormat = <DateType extends Date>(date: DateArg<DateType>) =>
formatDate(date, ISO_BASIC_DATETIME_FORMAT, { in: TZ });
/**
* Returns the next day of the given date, inclusive of the given day.
*
* If the given date is the given day, the same date is returned.
*
* For example, a Monday targeting a Wednesday will return the next Wednesday,
* but if it was targeting a Monday it would return the same date.
*
* @param date - The date to increment.
* @param day - The day to increment to. (0 = Sunday, 1 = Monday, etc.)
* @returns The next day of the given date, inclusive of the given day.
*/
export const nextDayInclusive = (date: Date, day: Day): TZDate => {
if (getDay(date, { in: TZ }) === day) {
return new TZDate(date, TIMEZONE_ID);
}
return nextDay(date, day, { in: TZ });
};
/**
* Returns an array of all the dates (as Date objects) in the given date ranges.
*
* @param dateRanges - An array of date ranges.
* Each date range can be a string (in which case it is interpreted as a single date)
* or an array of two strings (in which case it is interpreted as a date range, inclusive).
* @returns An array of all the dates (as Date objects) in the given date ranges.
*
* @example
* allDatesInRanges(['2025-01-01', ['2025-03-14', '2025-03-16']]) // ['2025-01-01', '2025-03-14', '2025-03-15', '2025-03-16'] (as Date objects)
*
* @remarks Does not remove duplicate dates.
*/
export const allDatesInRanges = (dateRanges: readonly (string | [string, string])[]): Date[] =>
dateRanges.flatMap(breakDate => {
if (Array.isArray(breakDate)) {
return eachDayOfInterval({
start: parseISO(breakDate[0], { in: TZ }),
end: parseISO(breakDate[1], { in: TZ }),
});
}
return parseISO(breakDate, { in: TZ });
});
/**
* Creates a VEVENT string for a meeting of a course.
*
* @param course - The course object
* @param meeting - The meeting object
* @returns A string representation of the meeting in the iCalendar format (ICS)
*/
export const meetingToIcsString = (course: Serialized<Course>, meeting: Serialized<CourseMeeting>): string | null => {
const { startTime, endTime, days, location } = meeting;
if (!course.semester.code) {
console.error(`No semester found for course uniqueId: ${course.uniqueId}`);
return null;
}
if (days.length === 0) {
console.error(`No days found for course uniqueId: ${course.uniqueId}`);
return null;
}
if (!Object.prototype.hasOwnProperty.call(academicCalendars, course.semester.code)) {
console.error(
`No academic calendar found for semester code: ${course.semester.code}; course uniqueId: ${course.uniqueId}`
);
return null;
}
const academicCalendar = academicCalendars[course.semester.code as keyof typeof academicCalendars];
const startDate = nextDayInclusive(
parseISO(academicCalendar.firstClassDate, { in: TZ }),
DAY_NAME_TO_NUMBER[days[0]!]
);
const startTimeHours = Math.floor(startTime / 60);
const startTimeMinutes = startTime % 60;
const startTimeDate = setMultiple(startDate, { hours: startTimeHours, minutes: startTimeMinutes }, { in: TZ });
const endTimeHours = Math.floor(endTime / 60);
const endTimeMinutes = endTime % 60;
const endTimeDate = setMultiple(startDate, { hours: endTimeHours, minutes: endTimeMinutes }, { in: TZ });
const untilDate = addDays(parseISO(academicCalendar.lastClassDate, { in: TZ }), 1);
const daysNumSet = new Set(days.map(d => DAY_NAME_TO_NUMBER[d]));
const excludedDates = allDatesInRanges(academicCalendar.breakDates)
// Don't need to exclude Tues/Thurs if it's a MWF class, etc.
.filter(date => daysNumSet.has(getDay(date, { in: TZ }) as Day))
.map(date => setMultiple(date, { hours: startTimeHours, minutes: startTimeMinutes }, { in: TZ }));
const startDateFormatted = iCalDateFormat(startTimeDate);
const endDateFormatted = iCalDateFormat(endTimeDate);
// Convert days to ICS compatible format, e.g. MO,WE,FR
const icsDays = days.map(day => CAL_MAP[day]).join(',');
// per spec, UNTIL must be in UTC
const untilDateFormatted = formatISO(untilDate, { format: 'basic', in: tz('utc') });
const excludedDatesFormatted = excludedDates.map(date => iCalDateFormat(date));
const uniqueNumberFormatted = course.uniqueId.toString().padStart(5, '0');
// The list part of "Taught by Michael Scott and Siddhartha Chatterjee Beasley"
const instructorsFormatted = englishStringifyList(
course.instructors
.map(instructor => Instructor.prototype.toString.call(instructor, { format: 'first_last' }))
.filter(name => name !== '')
);
// Construct event string
let icsString = 'BEGIN:VEVENT\n';
icsString += `DTSTART;TZID=${TIMEZONE_ID}:${startDateFormatted}\n`;
icsString += `DTEND;TZID=${TIMEZONE_ID}:${endDateFormatted}\n`;
icsString += `RRULE:FREQ=WEEKLY;BYDAY=${icsDays};UNTIL=${untilDateFormatted}\n`;
icsString += `EXDATE;TZID=${TIMEZONE_ID}:${excludedDatesFormatted.join(',')}\n`;
icsString += `SUMMARY:${course.department} ${course.number} \u2013 ${course.courseName}\n`;
if (location?.building || location?.building) {
const locationFormatted = `${location?.building ?? ''} ${location?.room ?? ''}`.trim();
icsString += `LOCATION:${locationFormatted}\n`;
}
icsString += `DESCRIPTION:Unique number: ${uniqueNumberFormatted}`;
if (instructorsFormatted) {
// Newlines need to be double-escaped
icsString += `\\nTaught by ${instructorsFormatted}`;
}
icsString += '\n';
icsString += 'END:VEVENT';
return icsString;
};
/**
* Creates a VCALENDAR string for a schedule of a user.
* @param schedule - The schedule object
* @returns A string representation of the schedule in the iCalendar format (ICS)
*/
export const scheduleToIcsString = (schedule: Serialized<UserSchedule>) => {
let icsString = 'BEGIN:VCALENDAR\nVERSION:2.0\nCALSCALE:GREGORIAN\nX-WR-CALNAME:My Schedule\n';
const vevents = schedule.courses
.flatMap(course => course.schedule.meetings.map(meeting => meetingToIcsString(course, meeting)))
.filter(event => event !== null)
.join('\n');
if (vevents.length > 0) {
icsString += `${vevents}\n`;
}
icsString += 'END:VCALENDAR';
return icsString;
};
/**
* Saves the current schedule as a calendar file in the iCalendar format (ICS).
* Fetches the current active schedule and converts it into an ICS string.
* Downloads the ICS file to the user's device.
*/
export const saveAsCal = async () => {
const schedule = await getSchedule(); // Assumes this fetches the current active schedule
let icsString = 'BEGIN:VCALENDAR\nVERSION:2.0\nCALSCALE:GREGORIAN\nX-WR-CALNAME:My Schedule\n';
const schedule = await getSchedule();
if (!schedule) {
throw new Error('No schedule found');
}
schedule.courses.forEach(course => {
course.schedule.meetings.forEach(meeting => {
const { startTime, endTime, days, location } = meeting;
// Format start and end times to HHMMSS
const formattedStartTime = formatToHHMMSS(startTime);
const formattedEndTime = formatToHHMMSS(endTime);
// Map days to ICS compatible format
console.log(days);
const icsDays = days.map(day => CAL_MAP[day]).join(',');
console.log(icsDays);
// Assuming course has date started and ended, adapt as necessary
// const year = new Date().getFullYear(); // Example year, adapt accordingly
// Example event date, adapt startDate according to your needs
const startDate = `20240101T${formattedStartTime}`;
const endDate = `20240101T${formattedEndTime}`;
icsString += `BEGIN:VEVENT\n`;
icsString += `DTSTART:${startDate}\n`;
icsString += `DTEND:${endDate}\n`;
icsString += `RRULE:FREQ=WEEKLY;BYDAY=${icsDays}\n`;
icsString += `SUMMARY:${course.fullName}\n`;
icsString += `LOCATION:${location?.building ?? ''} ${location?.room ?? ''}\n`;
icsString += `END:VEVENT\n`;
});
});
icsString += 'END:VCALENDAR';
const icsString = scheduleToIcsString(schedule);
downloadBlob(icsString, 'CALENDAR', 'schedule.ics');
};
@@ -92,31 +266,38 @@ export const saveAsCal = async () => {
* @param calendarRef - The reference to the calendar component.
*/
export const saveCalAsPng = () => {
const WIDTH_PX = 1165;
const HEIGHT_PX = 754;
const SCALE = 2;
const rootNode = document.createElement('div');
rootNode.style.backgroundColor = 'white';
rootNode.style.position = 'fixed';
rootNode.style.zIndex = '1000';
rootNode.style.top = '-10000px';
rootNode.style.left = '-10000px';
rootNode.style.width = '1165px';
rootNode.style.height = '754px';
rootNode.style.width = `${WIDTH_PX}px`;
rootNode.style.height = `${HEIGHT_PX}px`;
document.body.appendChild(rootNode);
const clonedNode = document.querySelector('#root')!.cloneNode(true) as HTMLDivElement;
clonedNode.style.backgroundColor = 'white';
(clonedNode.firstChild as HTMLDivElement).classList.add('screenshot-in-progress');
return new Promise<void>((resolve, reject) => {
requestAnimationFrame(async () => {
rootNode.appendChild(clonedNode);
const calendarTarget = clonedNode.querySelector('.screenshot\\:calendar-target') as HTMLDivElement;
calendarTarget.style.width = `${WIDTH_PX}px`;
calendarTarget.style.height = `${HEIGHT_PX}px`;
return new Promise<void>((resolve, reject) => {
rootNode.appendChild(clonedNode);
requestAnimationFrame(async () => {
try {
const screenshotBlob = await toBlob(clonedNode, {
cacheBust: true,
canvasWidth: 1165 * 2,
canvasHeight: 754 * 2,
canvasWidth: WIDTH_PX * SCALE,
canvasHeight: HEIGHT_PX * SCALE,
skipAutoScale: true,
pixelRatio: 2,
pixelRatio: SCALE,
});
if (!screenshotBlob) {

View File

@@ -46,7 +46,7 @@ export function usePrompt(): (info: PromptInfo, options?: DialogOptions) => void
{info.description}
</Text>
),
className: 'max-w-[400px] flex flex-col gap-2.5 p-6.25',
className: 'max-w-[415px] flex flex-col gap-2.5 p-6.25 border border-ut-offwhite/50',
},
options
);

View File

@@ -1,6 +1,7 @@
// import '@unocss/reset/tailwind-compat.css';
import 'uno.css';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import clsx from 'clsx';
import React, { forwardRef } from 'react';
@@ -8,6 +9,8 @@ import styles from './ExtensionRoot.module.scss';
export const styleResetClass = styles.extensionRoot;
const queryClient = new QueryClient();
/**
* A wrapper component for the extension elements that adds some basic styling to them
*/
@@ -16,7 +19,9 @@ export default function ExtensionRoot(props: React.HTMLProps<HTMLDivElement>): J
return (
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<div className={clsx(styleResetClass, 'h-full', className)} {...others} />
</QueryClientProvider>
</React.StrictMode>
);
}

View File

@@ -57,7 +57,7 @@ export default function FileUpload({
} satisfies React.CSSProperties
}
className={clsx(
'btn has-enabled:active:scale-96',
'btn',
{
'text-white! bg-opacity-100 hover:enabled:shadow-md active:enabled:shadow-sm shadow-black/20':
variant === 'filled',

View File

@@ -102,7 +102,7 @@ export function useMigrationDialog() {
{
title: 'This extension has updated!',
description:
"You may have already began planning your Spring '25 schedule. Click the button below to transfer your saved schedules into a new schedule. (You may be required to login to the UT Registrar)",
"You may have already began planning your Fall '25 schedule. Click the button below to transfer your saved schedules into a new schedule. (You may be required to login to the UT Registrar)",
buttons: close => <MigrationButtons close={close} />,
},

View File

@@ -24,8 +24,12 @@ export default function ScheduleDropdown({ defaultOpen, children }: ScheduleDrop
{({ open }) => (
<>
<DisclosureButton className='w-full flex items-center border-none bg-transparent px-3.5 py-2.5 text-left'>
<div className='flex-1'>
<Text as='div' variant='h3' className='w-100% text-ut-burntorange normal-case!'>
<div className='flex-1 min-w-0 overflow-hidden'>
<Text
as='div'
variant='h3'
className='w-full truncate whitespace-nowrap text-ut-burntorange normal-case!'
>
{activeSchedule ? activeSchedule.name : 'Schedule'}
</Text>
<div className='flex gap-2.5 text-theme-black leading-[75%]!'>

View File

@@ -109,12 +109,14 @@ export default function ScheduleListItem({ schedule, onClick }: ScheduleListItem
const handleDelete = () => {
showDialog({
title: 'Are you sure?',
title: 'Delete schedule?',
description: (
<>
<Text>Deleting</Text>
<Text className='text-ut-burntorange'> {schedule.name} </Text>
<Text>is permanent and will remove all added courses from that schedule.</Text>
<Text>Deleting </Text>
<Text className='text-ut-burntorange'>{schedule.name}</Text>
<Text> is permanent and will remove all added courses from </Text>
<Text className='text-ut-burntorange'>{schedule.name}</Text>
<Text>.</Text>
</>
),
// eslint-disable-next-line react/no-unstable-nested-components
@@ -126,12 +128,13 @@ export default function ScheduleListItem({ schedule, onClick }: ScheduleListItem
<Button
variant='filled'
color='theme-red'
icon={Trash}
onClick={() => {
close();
deleteSchedule(schedule.id);
}}
>
Delete Permanently
Delete permanently
</Button>
</>
),

View File

@@ -1,39 +0,0 @@
import Text from '@views/components/common/Text/Text';
import React from 'react';
/**
* Props for the Update Text
*/
export type UpdateTextProps = {
courses: string[];
};
/**
* UpdateText component displays a message indicating that the extension has been updated
* and lists the unique course numbers from the old version.
*
* @param courses - An array of course unique numbers to be displayed.
* @returns The rendered UpdateText component.
*/
export default function UpdateText({ courses }: UpdateTextProps): JSX.Element {
return (
<div className='max-w-64 flex flex-col justify-center gap-2'>
<div className='flex flex-col gap-0 text-center'>
<Text variant='h4' className='text-ut-burntorange'>
This extension has updated!
</Text>
<Text variant='p' className='text-ut-black'>
You may have already began planning your Spring 2025 schedule. Here are the Unique Numbers you had
from the old version: (Please open each link and re-add course to your new schedule)
</Text>
</div>
<div className='flex flex-col gap-1 text-center'>
{courses.map(course => (
<Text key={course} variant='p' className='text-ut-orange underline'>
{course}
</Text>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,148 @@
import type { IconProps } from '@phosphor-icons/react';
import { CloudX, Copy, Exam, MapPinArea, Palette } from '@phosphor-icons/react';
import { ExtensionStore } from '@shared/storage/ExtensionStore';
import Text from '@views/components/common/Text/Text';
import useWhatsNewPopUp from '@views/hooks/useWhatsNew';
import React, { useEffect, useState } from 'react';
/**
* This is the version of the 'What's New' features popup.
*
* It is used to check if the popup has already been shown to the user or not
*
* It should be incremented every time the "What's New" popup is updated.
*/
const WHATSNEW_POPUP_VERSION = 1;
const WHATSNEW_VIDEO_URL = 'https://cdn.longhorns.dev/whats-new-v2.1.2.mp4';
type Feature = {
id: string;
icon: React.ForwardRefExoticComponent<IconProps>;
title: string | JSX.Element;
description: string;
};
const NEW_FEATURES = [
{
id: 'custom-course-colors',
icon: Palette,
title: 'Custom Course Colors',
description: 'Paint your schedule in your favorite color theme',
},
{
id: 'quick-copy',
icon: Copy,
title: 'Quick Copy',
description: 'Quickly copy a course unique number to your clipboard',
},
{
id: 'updated-grades',
icon: Exam,
title: 'Updated Grades',
description: 'Fall 2024 grades are now available in the grade distribution',
},
{
id: 'ut-map',
icon: MapPinArea,
title: (
<div className='flex flex-row items-center'>
UTRP Map
<span className='mx-2 border border-ut-burntorange rounded px-2 py-0.5 text-xs text-ut-burntorange font-medium'>
BETA
</span>
</div>
),
description: 'Find directions to your classes with our beta map feature in the settings page',
},
] as const satisfies readonly Feature[];
/**
* WhatsNewPopupContent component.
*
* This component displays the content of the WhatsNew dialog.
* It shows the new features that have been added to the extension.
*
* @returns A JSX of WhatsNewPopupContent component.
*/
export default function WhatsNewPopupContent(): JSX.Element {
const [videoError, setVideoError] = useState(false);
return (
<div className='w-full flex flex-row justify-between'>
<div className='w-full flex flex-col-reverse items-center justify-between gap-spacing-7 md:flex-row'>
<div className='grid grid-cols-1 w-fit items-center gap-spacing-6 sm:grid-cols-2 md:w-[277px] md:flex md:flex-col md:flex-nowrap'>
{NEW_FEATURES.map(({ id, icon: Icon, title, description }) => (
<div key={id} className='w-full flex items-center justify-between gap-spacing-5'>
<Icon width='40' height='40' className='text-ut-burntorange' />
<div className='w-full flex flex-col gap-spacing-1'>
<Text variant='h4' className='text-ut-burntorange font-bold!'>
{title}
</Text>
<Text variant='p' className='text-ut-black'>
{description}
</Text>
</div>
</div>
))}
</div>
<div className='h-full max-w-[464px] w-full flex items-center justify-center'>
{videoError ? (
<div className='h-full w-full flex items-center justify-center border border-ut-offwhite/50 rounded'>
<div className='flex flex-col items-center justify-center p-spacing-2'>
<CloudX size={52} className='text-ut-black/50' />
<Text variant='h4' className='text-center text-ut-black/50'>
Failed to load video. Please try again later.
</Text>
</div>
</div>
) : (
<video
className='h-fit w-full flex items-center justify-center border border-ut-offwhite/50 rounded object-cover'
autoPlay
loop
muted
>
<source src={WHATSNEW_VIDEO_URL} type='video/mp4' onError={() => setVideoError(true)} />
</video>
)}
</div>
</div>
</div>
);
}
/**
* WhatsNewDialog component.
*
* This component is responsible for checking if the extension has already been updated
* and if so, it displays the WhatsNew dialog. Then it updates the state to show that the
* dialog has been shown.
*
* @returns An empty fragment.
*
* @remarks
* The component uses the `useWhatsNew` hook to show the WhatsNew dialog and the
* `useEffect` hook to perform the check on component mount. It also uses the `ExtensionStore`
* to view the state of the dialog.
*/
export function WhatsNewDialog(): null {
const showPopUp = useWhatsNewPopUp();
useEffect(() => {
const checkUpdate = async () => {
const version = await ExtensionStore.get('lastWhatsNewPopupVersion');
if (version !== WHATSNEW_POPUP_VERSION) {
await ExtensionStore.set('lastWhatsNewPopupVersion', WHATSNEW_POPUP_VERSION);
showPopUp();
}
};
checkUpdate();
// This is on purpose
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return null;
}

View File

@@ -140,7 +140,7 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
tickWidth: 1,
tickLength: 10,
tickColor: '#9CADB7',
crosshair: { color: extendedColors.theme.offwhite },
crosshair: { color: `${extendedColors.theme.offwhite}50` },
lineColor: '#9CADB7',
},
yAxis: {

View File

@@ -0,0 +1,89 @@
import ExtensionRoot from '@views/components/common/ExtensionRoot/ExtensionRoot';
import React, { useEffect, useState } from 'react';
import ReactDOM from 'react-dom';
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] as const;
/**
* Component that transforms the days dropdown into a series of checkboxes
* on the course catalog search page
*
* @returns The rendered checkbox component or null if the container is not found.
*/
export default function DaysCheckbox(): JSX.Element | null {
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const [daysValue, setDaysValue] = useState<number[]>([0, 0, 0, 0, 0, 0]);
useEffect(() => {
const daysDropdown = document.getElementById('mtg_days_st') as HTMLSelectElement | null;
if (!daysDropdown) {
console.error('Days dropdown not found');
return;
}
const formElement = daysDropdown.closest('.form_element')!;
const checkboxContainer = document.createElement('div');
// Create a hidden input to store the value
const hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.name = 'mtg_days_st';
hiddenInput.id = 'mtg_days_st_hidden';
hiddenInput.value = daysDropdown.value;
// Remove old dropdown
formElement.innerHTML = '';
// Add the label back
const newLabel = document.createElement('label');
newLabel.className = 'primary_label';
newLabel.htmlFor = 'mtg_days_st_hidden';
newLabel.textContent = 'AND days';
formElement.appendChild(newLabel);
formElement.appendChild(hiddenInput);
formElement.appendChild(checkboxContainer);
setContainer(checkboxContainer);
return () => {
checkboxContainer.remove();
};
}, []);
useEffect(() => {
// Update hidden input when daysValue changes
const hiddenInput = document.getElementById('mtg_days_st_hidden') as HTMLInputElement | null;
if (hiddenInput) {
hiddenInput.value = daysValue.join('');
}
}, [daysValue]);
const handleDayChange = (position: number, checked: boolean) => {
setDaysValue(prev => prev.with(position, checked ? 1 : 0));
};
if (!container) {
return null;
}
return ReactDOM.createPortal(
<ExtensionRoot>
<ul className='text-black font-[Verdana,_"Helvetica_Neue",_Helvetica,_Arial,_sans-serif]'>
{days.map((day, index) => (
<li key={day}>
<input
type='checkbox'
id={`day_${day}`}
checked={daysValue[index] === 1}
onChange={e => {
handleDayChange(index, e.target.checked);
}}
className='form-checkbox m-[3px_3px_3px_4px]'
/>{' '}
<label htmlFor={`day_${day}`}>{day}</label>
</li>
))}
</ul>
</ExtensionRoot>,
container
);
}

View File

@@ -1,14 +1,49 @@
import Link from '@views/components/common/Link';
import Text from '@views/components/common/Text/Text';
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import styles from './RecruitmentBanner.module.scss';
const DISCORD_URL = 'https://discord.gg/7pQDBGdmb7';
const GITHUB_URL = 'https://github.com/Longhorn-Developers/UT-Registration-Plus';
const DESIGNER_APPLICATION_URL =
'https://docs.google.com/forms/d/e/1FAIpQLSdX1Bb37tW6s1bkdIW3GJoTGcM_Uc-2DzFOFMXxGdn1jZ3K1A/viewform';
const RECRUIT_FROM_DEPARTMENTS = ['C S', 'ECE', 'MIS', 'CSE', 'EE', 'ITD', 'DES'];
// The lists below _must_ be mutually exclusive
const DEVELOPER_RECRUIT_FROM_DEPARTMENTS = new Set(['C S', 'ECE', 'MIS', 'CSE', 'EE', 'ITD']);
const DESIGNER_RECRUIT_FROM_DEPARTMENTS = new Set(['I', 'DES', 'AET']);
type RecruitmentType = 'DEVELOPER' | 'DESIGNER' | 'NONE';
const DeveloperRecruitmentBanner = () => (
<div className={styles.container}>
<Text className='text-white'>
Interested in helping us develop UT Registration Plus? Check out our{' '}
<Link className='text-ut-orange!' href={DISCORD_URL}>
Discord Server
</Link>{' '}
and{' '}
<Link className='text-ut-orange!' href={GITHUB_URL}>
GitHub
</Link>
!
</Text>
</div>
);
const DesignerRecruitmentBanner = () => (
<div className={styles.container}>
<Text className='text-white'>
Design for thousands of UT students through Longhorn Developers on real-world projects like UT Reg.
Plus.build your portfolio and collaborate in Figma. Apply{' '}
<Link className='text-ut-orange!' href={DESIGNER_APPLICATION_URL}>
here
</Link>
!
</Text>
</div>
);
/**
* This adds a new column to the course catalog table header.
@@ -17,47 +52,37 @@ const RECRUIT_FROM_DEPARTMENTS = ['C S', 'ECE', 'MIS', 'CSE', 'EE', 'ITD', 'DES'
*/
export default function RecruitmentBanner(): JSX.Element | null {
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const recruitmentType = useMemo<RecruitmentType>(getRecruitmentType, []);
useEffect(() => {
if (!canRecruitFrom()) {
if (recruitmentType === 'NONE') {
return;
}
const container = document.createElement('div');
container.setAttribute('id', 'ut-registration-plus-table-head');
const table = document.querySelector('table');
table!.before(container);
setContainer(container);
}, []);
}, [recruitmentType]);
if (!container) {
if (!container || recruitmentType === 'NONE') {
return null;
}
return createPortal(
<div className={styles.container}>
<Text color='white'>
Interested in helping us develop UT Registration Plus? Check out our{' '}
<Link color='white' href={DISCORD_URL}>
Discord Server
</Link>{' '}
and{' '}
<Link color='white' href={GITHUB_URL}>
GitHub
</Link>
!
</Text>
</div>,
recruitmentType === 'DEVELOPER' ? <DeveloperRecruitmentBanner /> : <DesignerRecruitmentBanner />,
container
);
}
/**
* Determines if recruitment can be done from the current department.
* Determines what type of recruitment can be done from the current department.
*
* @returns True if recruitment can be done from the current department, false otherwise.
* @returns 'DEVELOPER' or 'DESIGNER' if the current department recruits for that respective type, otherwise 'NONE'
*/
export const canRecruitFrom = (): boolean => {
export const getRecruitmentType = (): RecruitmentType => {
const params = ['fos_fl', 'fos_cn'];
let department = '';
params.forEach(p => {
@@ -66,8 +91,18 @@ export const canRecruitFrom = (): boolean => {
department = param;
}
});
if (!department) {
return false;
return 'NONE';
}
return RECRUIT_FROM_DEPARTMENTS.includes(department);
if (DEVELOPER_RECRUIT_FROM_DEPARTMENTS.has(department)) {
return 'DEVELOPER';
}
if (DESIGNER_RECRUIT_FROM_DEPARTMENTS.has(department)) {
return 'DESIGNER';
}
return 'NONE';
};

View File

@@ -595,7 +595,9 @@ export default function Settings(): JSX.Element {
<section className='my-8 lg:my-0 lg:ml-4 lg:w-1/2'>
<section>
<h2 className='mb-4 text-xl text-ut-black font-semibold'>LONGHORN DEVELOPERS ADMINS</h2>
<h2 className='mb-4 text-xl text-ut-black font-semibold'>
LONGHORN DEVELOPERS (LHD) EXECUTIVE BOARD
</h2>
<div className='grid grid-cols-2 gap-4 2xl:grid-cols-4 md:grid-cols-3'>
{LONGHORN_DEVELOPERS_ADMINS.map(admin => (
<div
@@ -611,7 +613,11 @@ export default function Settings(): JSX.Element {
>
{admin.name}
</Text>
<p className='text-sm text-gray-600'>{admin.role}</p>
{admin.role.map(role => (
<p key={admin.githubUsername} className='text-sm text-gray-600'>
{role}
</p>
))}
{showGitHubStats && githubStats && (
<div className='mt-2'>
<p className='text-xs text-gray-500'>GitHub Stats (UTRP repo):</p>
@@ -657,7 +663,11 @@ export default function Settings(): JSX.Element {
>
{swe.name}
</Text>
<p className='text-sm text-gray-600'>{swe.role}</p>
{swe.role.map(role => (
<p key={swe.githubUsername} className='text-sm text-gray-600'>
{role}
</p>
))}
{showGitHubStats && githubStats && (
<div className='mt-2'>
<p className='text-xs text-gray-500'>GitHub Stats (UTRP repo):</p>

View File

@@ -5,7 +5,7 @@ import { convertMinutesToIndex } from '../useFlattenedCourseSchedule';
describe('useFlattenedCourseSchedule', () => {
it('should convert minutes to index correctly', () => {
const minutes = 480; // 8:00 AM
const expectedIndex = 2; // (480 - 420) / 30 = 2
const expectedIndex = 3; // (480 - 480) / 30 + 2 + 1 = 3
const result = convertMinutesToIndex(minutes);
expect(result).toBe(expectedIndex);
});

View File

@@ -21,19 +21,19 @@ export function useEnforceScheduleLimit(): () => boolean {
return useCallback(() => {
if (schedules.length >= SCHEDULE_LIMIT) {
showDialog({
title: `You have ${SCHEDULE_LIMIT} active schedules!`,
title: `You have too many schedules!`,
description: (
<>
To encourage organization,{' '}
<span className='text-ut-burntorange'>please consider removing some unused schedules</span> you
<span className='text-ut-burntorange'>please consider deleting any unused schedules</span> you
may have.
</>
),
buttons: close => (
<Button variant='filled' color='ut-burntorange' onClick={close}>
I Understand
I understand
</Button>
),
});

View File

@@ -52,7 +52,9 @@ export interface FlattenedCourseSchedule {
* @param minutes - The number of minutes.
* @returns The index value.
*/
export const convertMinutesToIndex = (minutes: number): number => Math.floor((minutes - 420) / 30);
export const convertMinutesToIndex = (minutes: number): number =>
// 480 = 8 a.m., 30 = 30 minute slots, 2 header rows, and grid rows start at 1
Math.floor((minutes - 480) / 30) + 2 + 1;
/**
* Get the active schedule, and convert it to be render-able into a calendar.

View File

@@ -0,0 +1,46 @@
import { Button } from '@views/components/common/Button';
import Text from '@views/components/common/Text/Text';
import WhatsNewPopupContent from '@views/components/common/WhatsNewPopup';
import { useDialog } from '@views/contexts/DialogContext';
import React from 'react';
import { LogoIcon } from '../components/common/LogoIcon';
import useChangelog from './useChangelog';
/**
* Custom hook that provides a function to display a what's new dialog.
*
* @returns A function that, when called, shows a dialog with the changelog.
*/
export default function useWhatsNewPopUp(): () => void {
const showDialog = useDialog();
const showChangeLog = useChangelog();
const { version } = chrome.runtime.getManifest();
const showPopUp = () => {
showDialog(close => ({
className: 'w-[830px] flex flex-col items-center gap-spacing-7 p-spacing-8',
title: (
<div className='flex items-center justify-between gap-4'>
<LogoIcon width='48' height='48' />
<Text variant='h1' className='text-theme-black'>
What&apos;s New in UT Registration Plus
</Text>
</div>
),
description: <WhatsNewPopupContent />,
buttons: (
<div className='flex flex-row items-end gap-spacing-4'>
<Button onClick={showChangeLog} variant='minimal' color='ut-black'>
Read Changelog v{version}
</Button>
<Button onClick={close} color='ut-burntorange'>
Get started
</Button>
</div>
),
}));
};
return showPopUp;
}

View File

@@ -5,7 +5,7 @@ import type { CachedData } from '@shared/types/CachedData';
// Types
type TeamMember = {
name: string;
role: string;
role: string[];
githubUsername: string;
};
@@ -40,21 +40,38 @@ const REPO_NAME = 'UT-Registration-Plus';
const CONTRIBUTORS_API_ROUTE = `/repos/${REPO_OWNER}/${REPO_NAME}/stats/contributors`;
export const LONGHORN_DEVELOPERS_ADMINS = [
{ name: 'Sriram Hariharan', role: 'Founder', githubUsername: 'sghsri' },
{ name: 'Elie Soloveichik', role: 'Staff Engineer', githubUsername: 'Razboy20' },
{ name: 'Diego Perez', role: 'Staff Engineer', githubUsername: 'doprz' },
{ name: 'Lukas Zenick', role: 'Senior Software Engineer', githubUsername: 'Lukas-Zenick' },
{ name: 'Isaiah Rodriguez', role: 'Chief Operations and Design Officer', githubUsername: 'IsaDavRod' },
{ name: 'Samuel Gunter', role: 'Senior Software Engineer', githubUsername: 'Samathingamajig' },
{ name: 'Derek Chen', role: 'Senior Software Engineer', githubUsername: 'DereC4' },
{ name: 'Sriram Hariharan', role: ['LHD Co-Founder', 'UTRP Founder'], githubUsername: 'sghsri' },
{
name: 'Elie Soloveichik',
role: ['LHD Co-Founder', 'Learning and Development Director', 'UTRP Senior SWE'],
githubUsername: 'Razboy20',
},
{
name: 'Diego Perez',
role: ['LHD Co-Founder', 'Software Engineering Director', 'UTRP Senior SWE'],
githubUsername: 'doprz',
},
{ name: 'Isaiah Rodriguez', role: ['LHD Co-Founder', 'President and UI/UX Director'], githubUsername: 'IsaDavRod' },
{
name: 'Samuel Gunter',
role: ['Administrative Director', 'UTRP Co-Lead', 'UTRP Senior SWE'],
githubUsername: 'Samathingamajig',
},
{
name: 'Derek Chen',
role: ['Communications Director', 'UTRP Co-Lead', 'UTRP Senior SWE'],
githubUsername: 'DereC4',
},
{ name: 'Kabir Ramzan', role: ['Events Director'], githubUsername: 'CMEONE' },
] as const satisfies TeamMember[];
export const LONGHORN_DEVELOPERS_SWE = [
{ name: 'Preston Cook', role: 'Software Engineer', githubUsername: 'Preston-Cook' },
{ name: 'Ethan Lanting', role: 'Software Engineer', githubUsername: 'EthanL06' },
{ name: 'Casey Charleston', role: 'Software Engineer', githubUsername: 'caseycharleston' },
{ name: 'Vinson', role: 'Software Engineer', githubUsername: 'vinsonzheng499' },
{ name: 'Vivek', role: 'Software Engineer', githubUsername: 'vivek12311' },
{ name: 'Preston Cook', role: ['Software Engineer'], githubUsername: 'Preston-Cook' },
{ name: 'Ethan Lanting', role: ['Software Engineer'], githubUsername: 'EthanL06' },
{ name: 'Casey Charleston', role: ['Software Engineer'], githubUsername: 'caseycharleston' },
{ name: 'Lukas Zenick', role: ['LHD Alumni', 'Senior Software Engineer'], githubUsername: 'Lukas-Zenick' },
{ name: 'Vinson', role: ['LHD Alumni', 'Software Engineer'], githubUsername: 'vinsonzheng499' },
{ name: 'Vivek', role: ['LHD Alumni', 'Software Engineer'], githubUsername: 'vivek12311' },
] as const satisfies TeamMember[];
/**

View File

@@ -13,6 +13,7 @@ export const SiteSupport = {
MY_CALENDAR: 'MY_CALENDAR',
REPORT_ISSUE: 'REPORT_ISSUE',
MY_UT: 'MY_UT',
COURSE_CATALOG_SEARCH: 'COURSE_CATALOG_SEARCH',
CLASSLIST: 'CLASSLIST',
} as const;
@@ -45,6 +46,7 @@ export default function getSiteSupport(url: string): SiteSupportType | null {
if (document.querySelector('#details')) {
return SiteSupport.COURSE_CATALOG_DETAILS;
}
return SiteSupport.COURSE_CATALOG_SEARCH;
}
if (url.includes('utdirect.utexas.edu') && (url.includes('waitlist') || url.includes('classlist'))) {
return SiteSupport.WAITLIST;

View File

@@ -26,7 +26,7 @@ export default defineConfig({
],
shortcuts: {
focusable: 'outline-none ring-blue-500/50 dark:ring-blue-400/60 ring-0 focus-visible:ring-4',
btn: 'h-10 w-auto flex cursor-pointer justify-center items-center gap-spacing-3 rounded-1 px-spacing-5 py-0 text-4.5 btn-transition disabled:(cursor-not-allowed opacity-50) active:enabled:scale-96 focusable',
btn: 'h-10 w-auto flex cursor-pointer justify-center items-center gap-spacing-3 rounded-1 px-spacing-5 py-0 text-4.5 btn-transition disabled:(cursor-not-allowed opacity-50) active:enabled:scale-96 active:has-enabled:scale-96 focusable',
link: 'text-ut-burntorange link:text-ut-burntorange underline underline-offset-2 hover:text-ut-orange focus-visible:text-ut-orange focusable btn-transition ease-out-expo',
linkanimate:
'relative cursor-pointer transition duration-100 ease-out after:(absolute left-0.4 right-0.4 h-2px scale-x-95 bg-ut-orange opacity-0 transition duration-250 ease-out-expo content-empty -bottom-0.75 -translate-y-0.5) active:scale-95 hover:text-ut-orange focus-visible:text-ut-orange hover:after:(opacity-100) !hover:after:translate-y-0 !hover:after:scale-x-100',