Compare commits

..

71 Commits

Author SHA1 Message Date
doprz
e13722820b refactor: use a local changelog 2025-10-15 12:22:59 -05:00
doprz
e0067f8a76 fix: delete 2025-10-15 12:20:14 -05:00
doprz
a66fd151dd chore: add Sentry env vars to workflow 2025-10-14 20:25:04 -05:00
doprz
006d375605 fix: scripts and CI/CD 2025-10-14 20:06:46 -05:00
doprz
4bbddacabb revert: use cachix/install-nix-action 2025-10-14 16:36:57 -05:00
doprz
18530d9d45 fix: disable flakehub 2025-10-14 16:32:53 -05:00
doprz
3d3e8ced6f refactor(nix): use DeterminateSystems nix CI GHAs 2025-10-14 16:27:56 -05:00
doprz
c9df1bf344 feat(ci): add GHA jobs for release 2025-10-13 14:12:29 -05:00
doprz
d8216aefb6 feat(ci): add nix job 2025-10-13 13:48:00 -05:00
doprz
aeac1bab5b chore: remove .releaserc.json 2025-10-13 13:24:04 -05:00
doprz
1a0757c3e6 feat: add changesets cli 2025-10-13 13:22:17 -05:00
dependabot[bot]
e8a8b8e1ae chore(deps): bump the npm_and_yarn group across 1 directory with 4 updates (#639)
Bumps the npm_and_yarn group with 4 updates in the / directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite), [@babel/helpers](https://github.com/babel/babel/tree/HEAD/packages/babel-helpers), [brace-expansion](https://github.com/juliangruber/brace-expansion) and [undici](https://github.com/nodejs/undici).


Updates `vite` from 5.4.14 to 5.4.20
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.20/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.20/packages/vite)

Updates `@babel/helpers` from 7.26.9 to 7.28.4
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.28.4/packages/babel-helpers)

Updates `brace-expansion` from 1.1.11 to 1.1.12
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/1.1.11...v1.1.12)

Updates `undici` from 6.21.1 to 6.22.0
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.21.1...v6.22.0)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 5.4.20
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: "@babel/helpers"
  dependency-version: 7.28.4
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: brace-expansion
  dependency-version: 1.1.12
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: undici
  dependency-version: 6.22.0
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-13 11:36:53 -05:00
doprz
c21cbd77f0 feat(release): v2.2.2 2025-10-13 11:20:01 -05:00
99a035e29d chore: remove summer 2025 schedule list item (#637) 2025-10-13 02:25:27 -05:00
doprz
64baa6d290 refactor(nix): dev shells (#634) 2025-10-12 22:47:47 -05:00
Samuel Gunter
46fe591fa7 fix: whitespace wrapping in semester warning (#629) 2025-10-07 18:28:41 -05:00
doprz
8f7e1bc0af feat(env): add SENTRY env vars 2025-10-07 16:11:14 -05:00
doprz
9fc1098ef7 chore(env): add .env.example 2025-10-07 16:00:40 -05:00
Warith Rahman
ae094416fc chore(ui): added spring 2026 course schedule (#628) 2025-10-07 15:26:15 -05:00
ishita778
2e7dac1e3e feat: show warning for courses of different semesters (#570)
* chore: removed extra space at calendar footer

* chore: fixed eslint issues

* chore: changed return type to react node

* chore: displaycourses true fixes and checks fixed

* chore: prettier fix

* feat: not working same semester course issue

* feat: modifying components to use the new hook

* feat: small fixes

* fix: remove comments and spaces

* fix: dialog error solved

* fix: add to new schedule

* fix: prettier

* fix: delete unnecessary custom hook and p[rettier

* fix: checks all passing

* fix: added requested changes

* fix: added new conditions

* fix: description fixed

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

* fix: made requested changes

---------

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

* chore(nix): remove just and update comment

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

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

* chore(nix): remove just and update comment

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

* feat: function

* feat: stuff before bedtime

* feat: shading function

* feat: shading

* feat: shading the table children

* chore: fix lint issues

* feat: dependency array

* feat: remove

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

* docs: hypen bruh

* chore: lowercase

* style: filecode icon

* chore: unused import

* refactor: use export json deleted old function

* chore: linting

* chore: remove useless import

---------

Co-authored-by: Samuel Gunter <29130894+Samathingamajig@users.noreply.github.com>
2025-06-17 11:57:48 -07:00
doprz
7b401add15 feat: add nix flake (#593)
Co-authored-by: Samuel Gunter <29130894+Samathingamajig@users.noreply.github.com>
2025-06-08 23:16:43 -05:00
Samuel Gunter
2d92dd47f0 feat: support summer grades, fix summer course parser (#596)
* feat: support summer grades, fix summer course parser

* chore: lint

* docs: mention summer terms in Course::number description

* feat: Course::getNumberWithoutTerm, strip summer term indicator when displaying grades

---------

Co-authored-by: doprz <52579214+doprz@users.noreply.github.com>
2025-06-08 21:10:05 -07:00
Aiyaz Mostofa
eb8141ee8c fix: limit height of schedule list dropdown in the extension popup (#543)
* fix: limit height of schedule list dropdown in the extension popup

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

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

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

---------

Co-authored-by: Ethan Lanting <ethanlanting@gmail.com>
Co-authored-by: Samuel Gunter <29130894+Samathingamajig@users.noreply.github.com>
Co-authored-by: doprz <52579214+doprz@users.noreply.github.com>
2025-06-08 20:54:36 -07:00
sjalkote
2a50f5580d feat: automatically select new or duplicated schedules (#583) (#589)
Co-authored-by: doprz <52579214+doprz@users.noreply.github.com>
Co-authored-by: Samuel Gunter <29130894+Samathingamajig@users.noreply.github.com>
2025-06-08 20:39:46 -07:00
doprz
65bfb1d129 docs: add pnpm and update versions (#597) 2025-06-08 15:57:12 -05:00
doprz
234f3d627d feat(release): v2.2.1 2025-06-03 19:46:36 -05:00
Ethan Lanting
be1dccfcb9 feat: add dining app promo (#598)
* feat: add DiningAppPromo component and integrate it into Calendar

* feat: update WhatsNewPopup with new features and app download link

* fix: remove outdated links

* chore: run lint

* chore: run prettier

* feat: enhance DiningAppPromo with close button and integrate user preference for promo visibility

* chore: run lint

* chore: run check types

* fix: correct promo visibility logic in Calendar component

* feat: centralize app store URLs in appUrls.ts

* chore: run lint

* feat: integrate UT Dining promo image

* chore: run lint

* fix: update logo in WhatsNew popup to use LD icon

* fix: convert URLs to URL objects for consistency

* fix: update LD icon in WhatsNew popup to new version

* fix: update description for Coffee Shops feature to clarify operating times

* fix: rename promo state and storage key to showUTDiningPromo for clarity

---------

Co-authored-by: doprz <52579214+doprz@users.noreply.github.com>
2025-05-28 20:13:45 -05:00
Razboy20
454e5e807a fix: hide sentry instrumentation on debug builds (#604)
* fix: hide sentry instrumentation on debug builds

* fix: amend documentation to reference zip:to-public rather than zip
2025-05-27 23:35:11 -05:00
Razboy20
29d20d5c5a chore: setup dependabot configuration (#603)
* chore: setup dependabot configuration
2025-05-27 22:55:11 -05:00
doprz
e29546c727 chore: update issue templates 2025-05-27 21:55:36 -05:00
doprz
5a89be6238 chore: update issue templates 2025-05-27 21:53:42 -05:00
Samuel Gunter
cfb5faa09b fix: course columns on calendar (#587)
* fix: course columns on calendar

* test: new tests for course cell columns, extract calls to help function

* fix: gracefully handle async courses

* refactor: split course cell column logic into multiple functions, add comments, and derive fields

* chore: comments, for-index to for-of

* chore: fix typo

* fix: don't use sentry in storybook contexts

* fix: silence, brand

* refactor: don't rely on calculating columns, find as you go

---------

Co-authored-by: doprz <52579214+doprz@users.noreply.github.com>
2025-05-06 16:07:27 -05:00
37471efb74 feat: inside jokes005 (#590)
* feat: moved some of my lines together removed one of mine and added jack black

* feat: just ctrl x ctrl v a few lines

* Update insideJokes.tsx
2025-04-13 21:33:52 -05:00
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
doprz
195d3a219a chore(release): v2.1.1 2025-03-02 21:01:18 -06:00
doprz
c6452c4f2b feat: add isDeveloper 2025-03-02 20:47:31 -06:00
doprz
3dbacf0d65 chore: pin node@20.9.0 (#540) 2025-03-01 12:54:31 -06:00
doprz
56a82baf40 chore: update lock file and set engine to use pnpm v10 (#536)
* chore: update lock file and set engine to use pnpm v10.5.2

* chore: set pnpm version to v10.x.x

* chore(ci): use pnpm v10

* chore: update Dockerfile to use pnpm@latest-10

* chore(ci): use pnpm/action-setup@v4
2025-02-28 22:54:46 -06:00
ishita778
20d210245b chore: rename unscheduled text at calendar footer (#538) 2025-02-28 11:46:19 -06:00
ishita778
21cbcf677a chore: changed text to data source (#537) 2025-02-28 11:07:19 -06:00
Samuel Gunter
8fb355d03b chore(cal): update course schedule links for fall and summer 2025 (#534) 2025-02-27 23:37:38 -06:00
doprz
218477404f feat: map page (#390)
* feat: add boilerplate

* feat: add working paths

* feat: improve building selection controls and add week schedule

Signed-off-by: doprz <52579214+doprz@users.noreply.github.com>

* fix: sort week schedule

Signed-off-by: doprz <52579214+doprz@users.noreply.github.com>

* feat(testing): improve pathfinding

* Revert "feat(testing): improve pathfinding"

This reverts commit 87998cedbf.

* feat: add pathfinding with building selection controls

Signed-off-by: doprz <52579214+doprz@users.noreply.github.com>

* feat: improve path finding algorithm thresholds

* feat: add DaySelector, PathStats, and WeekSchedule components

* feat: add working stats and daily schedule

* chore: refactor everything

* feat: add linear walkway node generation

* feat: add bezier curve walkway node generation

* feat: add circular walkway node generation

* docs: add docs

* feat: add individual path selection and bump version

* fix: tsdoc and updated components/utils

* chore(deps): update deps

* feat: add UTRP Map and Debug Page to Settings > Developer Mode

* chore: fix pr review comments

* chore: add showDebugNodes

* chore: add all buildings around the UT tower

* chore: add stadium POIs

* chore: add east mall buildings

* chore: update DaySelector to use proper button styling

* chore: add university ave walkway

* feat: add zoom, pan, and dev controls functionality

- Fix SVG Overlay Alignment
- Use SVG for map
- Add Dev Controls
- Fix day selector position
- Update the SVG's `preserveAspectRatio` attribute to `xMidYMid` meet to
ensure proper scaling
- Use `useCallback` for event handlers to prevent unnecessary re-renders
- Remove old PNG map

* feat: add dynamic rendering"

* feat: add dynamicRendering dev toggle and fullscreen support

* chore: update deps

* chore: disable viewport svg overlay culling if dynamic rendering is off

* chore: update pnpm-lock.yaml

* chore: add north mall buildings

* chore: add buildings next to JES

* refactor: map components into individual files

* fix: missing import

---------

Signed-off-by: doprz <52579214+doprz@users.noreply.github.com>
2025-02-27 19:44:03 -06:00
Ethan Lanting
766c0bc1b4 fix: import schedule file upload button (#515)
* fix: add active scale style and accept file prop

* chore: improve type safety using MIMEType

* fix: update FileUpload component to support multiple MIME types in accept prop

* chore: run lint

---------

Co-authored-by: doprz <52579214+doprz@users.noreply.github.com>
2025-02-24 20:17:49 -06:00
Samuel Gunter
4a8d0666c2 chore: update grades as of fall 2024 (#508) 2025-02-21 16:09:53 -06:00
114 changed files with 14787 additions and 1983 deletions

8
.changeset/README.md Normal file
View File

@@ -0,0 +1,8 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)

View File

@@ -0,0 +1,5 @@
---
'ut-registration-plus': minor
---
Add CI/CD

11
.changeset/config.json Normal file
View File

@@ -0,0 +1,11 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}

View File

@@ -1,18 +1,23 @@
# File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig # File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig
# Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,macos,node,react,storybookjs # Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,macos,node,react,storybookjs
# Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,macos,node,react,storybookjs # Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,macos,node,react,storybookjs
### macOS ### ### macOS
# General # General
.DS_Store .DS_Store
.AppleDouble .AppleDouble
.LSOverride .LSOverride
# Thumbnails # Thumbnails
._*
.\_\*
# Files that might appear in the root of a volume # Files that might appear in the root of a volume
.DocumentRevisions-V100 .DocumentRevisions-V100
.fseventsd .fseventsd
.Spotlight-V100 .Spotlight-V100
@@ -22,221 +27,278 @@
.com.apple.timemachine.donotpresent .com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share # Directories potentially created on remote AFP share
.AppleDB .AppleDB
.AppleDesktop .AppleDesktop
Network Trash Folder Network Trash Folder
Temporary Items Temporary Items
.apdisk .apdisk
### macOS Patch ### ### macOS Patch
# iCloud generated files
*.icloud # iCloud generated files
\*.icloud
### Node
### Node ###
# Logs # Logs
logs logs
*.log _.log
npm-debug.log* npm-debug.log_
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
lerna-debug.log* lerna-debug.log*
.pnpm-debug.log* .pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html) # Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data # Runtime data
pids pids
*.pid _.pid
*.seed _.seed
*.pid.lock \*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover # Directory for instrumented libs generated by jscoverage/JSCover
lib-cov lib-cov
# Coverage directory used by tools like istanbul # Coverage directory used by tools like istanbul
coverage coverage
*.lcov \*.lcov
# nyc test coverage # nyc test coverage
.nyc_output .nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt .grunt
# Bower dependency directory (https://bower.io/) # Bower dependency directory (https://bower.io/)
bower_components bower_components
# node-waf configuration # node-waf configuration
.lock-wscript .lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html) # Compiled binary addons (https://nodejs.org/api/addons.html)
build build
# Dependency directories # Dependency directories
node_modules/ node_modules/
jspm_packages/ jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/) # Snowpack dependency directory (https://snowpack.dev/)
web_modules/ web_modules/
# TypeScript cache # TypeScript cache
*.tsbuildinfo
\*.tsbuildinfo
# Optional npm cache directory # Optional npm cache directory
.npm .npm
# Optional eslint cache # Optional eslint cache
.eslintcache .eslintcache
# Optional stylelint cache # Optional stylelint cache
.stylelintcache .stylelintcache
# Microbundle cache # Microbundle cache
.rpt2_cache/ .rpt2_cache/
.rts2_cache_cjs/ .rts2_cache_cjs/
.rts2_cache_es/ .rts2_cache_es/
.rts2_cache_umd/ .rts2_cache_umd/
# Optional REPL history # Optional REPL history
.node_repl_history .node_repl_history
# Output of 'npm pack' # Output of 'npm pack'
*.tgz
\*.tgz
# Yarn Integrity file # Yarn Integrity file
.yarn-integrity .yarn-integrity
# dotenv environment variable files # dotenv environment variable files
.env .env
.env.development.local .env.development.local
.env.test.local .env.test.local
.env.production.local .env.production.local
.env.local .env.local
.env.* .env.\*
!.env.example !.env.example
# parcel-bundler cache (https://parceljs.org/) # parcel-bundler cache (https://parceljs.org/)
.cache .cache
.parcel-cache .parcel-cache
# Next.js build output # Next.js build output
.next .next
out out
# Nuxt.js build / generate output # Nuxt.js build / generate output
.nuxt .nuxt
dist dist
# Gatsby files # Gatsby files
.cache/ .cache/
# Comment in the public line in if your project uses Gatsby and not Next.js # Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support # https://nextjs.org/blog/next-9-1#public-directory-support
# public # public
# vuepress build output # vuepress build output
.vuepress/dist .vuepress/dist
# vuepress v2.x temp and cache directory # vuepress v2.x temp and cache directory
.temp .temp
# Docusaurus cache and generated files # Docusaurus cache and generated files
.docusaurus .docusaurus
# Serverless directories # Serverless directories
.serverless/ .serverless/
# FuseBox cache # FuseBox cache
.fusebox/ .fusebox/
# DynamoDB Local files # DynamoDB Local files
.dynamodb/ .dynamodb/
# TernJS port file # TernJS port file
.tern-port .tern-port
# Stores VSCode versions used for testing VSCode extensions # Stores VSCode versions used for testing VSCode extensions
.vscode-te .vscode-te
node_modulesst node_modulesst
# yarn v2 # yarn v2
.yarn/cache .yarn/cache
.yarn/unplugged .yarn/unplugged
.yarn/build-state.yml .yarn/build-state.yml
.yarn/install-state.gz .yarn/install-state.gz
.pnp.* .pnp.\*
### Node Patch
### Node Patch ###
# Serverless Webpack directories # Serverless Webpack directories
.webpack/ .webpack/
# Optional stylelint cache # Optional stylelint cache
# SvelteKit build / generate output # SvelteKit build / generate output
.svelte-kit .svelte-kit
### react ### ### react
.DS_*
**/*.backup.*
**/*.back.*
*.sublime* .DS\__
\*\*/_.backup._
\*\*/_.back.\*
_.sublime_
psd psd
thumb thumb
sketch sketch
### VisualStudioCode
### VisualStudioCode ### .vscode/_
.vscode/*
!.vscode/settings.json !.vscode/settings.json
!.vscode/tasks.json !.vscode/tasks.json
!.vscode/launch.json !.vscode/launch.json
!.vscode/extensions.json !.vscode/extensions.json
!.vscode/*.code-snippets !.vscode/_.code-snippets
# Local History for Visual Studio Code # Local History for Visual Studio Code
.history/ .history/
# Built Visual Studio Code Extensions # Built Visual Studio Code Extensions
*.vsix
### VisualStudioCode Patch ### \*.vsix
### VisualStudioCode Patch
# Ignore all local history of files # Ignore all local history of files
.history .history
.ionide .ionide
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,macos,node,react,storybookjs # End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,macos,node,react,storybookjs
# Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option)
package-lock.json package-lock.json
storybook-static/ storybook-static/
package/ package/
# Version control # Version control
.git .git
.gitignore .gitignore
.gitattributes .gitattributes
# Dependencies # Dependencies
.pnpm-store .pnpm-store
!pnpm-lock.yaml !pnpm-lock.yaml
# Testing # Testing
coverage coverage
.nyc_output .nyc_output
# OS files # OS files
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Docker # Docker
Dockerfile Dockerfile
.dockerignore .dockerignore
docker-compose* docker-compose\*
# Documentation # Documentation
README.md README.md
CHANGELOG.md CHANGELOG.md
CHANGELOG-local.md
DOCKER_DEV_SETUP.md DOCKER_DEV_SETUP.md
docs/ docs/

View File

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

3
.env.example Normal file
View File

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

1
.envrc Normal file
View File

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

View File

@@ -1,9 +1,10 @@
--- ---
name: Bug report name: Bug report
about: Create a report to help us improve about: Create a report to help us improve
title: '[BUG] ' title: "[BUG] "
labels: 'bug' labels: ''
assignees: '' assignees: ''
--- ---
**Pre-submission Checklist** **Pre-submission Checklist**

View File

@@ -1,9 +1,10 @@
--- ---
name: Feature Request name: Feature Request
about: Suggest an idea for this project about: Suggest an idea for this project
title: '[FEATURE] ' title: "[FEATURE] "
labels: 'feature' labels: feature
assignees: '' assignees: ''
--- ---
**Pre-submission Checklist** **Pre-submission Checklist**

View File

@@ -0,0 +1,12 @@
---
name: Updating Build Dependencies
about: Updating Build Dependencies
title: ''
labels: build, dependencies
assignees: doprz, Razboy20
---
- [ ] Updated Nix Flake
- [ ] Update Dockerfile
- [ ] Update Docs

21
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
version: 2
updates:
- package-ecosystem: 'npm'
directory: '/'
schedule:
interval: 'weekly'
day: 'monday'
time: '09:00'
timezone: 'America/Chicago'
groups:
minor-and-patch-updates:
update-types:
- 'minor'
- 'patch'
major-updates:
update-types:
- 'major'
ignore:
- dependency-name: '@crxjs/vite-plugin'
- dependency-name: '@unocss/vite'

View File

@@ -13,9 +13,9 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v4
with: with:
version: 9 version: 10
- name: Install dependencies - name: Install dependencies
run: pnpm install run: pnpm install
@@ -32,9 +32,9 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v4
with: with:
version: 9 version: 10
- name: Install dependencies - name: Install dependencies
run: pnpm install run: pnpm install

View File

@@ -13,9 +13,9 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v4
with: with:
version: 9 version: 10
- name: Install dependencies - name: Install dependencies
run: pnpm install run: pnpm install

View File

@@ -11,9 +11,9 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v4
with: with:
version: 9 version: 10
- name: Install dependencies - name: Install dependencies
run: pnpm install run: pnpm install

View File

@@ -1,25 +1,77 @@
name: Create Release name: Release
on: on:
pull_request:
push: push:
branches: branches:
- production - main
- preview
jobs:
build:
name: build extension & create release
runs-on: ubuntu-latest
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
steps:
- uses: actions/checkout@master
- name: Get file permission
run: chmod -R 777 .
- name: Install dependencies concurrency: ${{ github.workflow }}-${{ github.ref }}
run: npm ci
- name: Release with semantic-release jobs:
id: semantic-release nix-check:
run: npx --no-install semantic-release runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: cachix/install-nix-action@v31
with:
github_access_token: ${{ secrets.GITHUB_TOKEN }}
- run: nix flake check
release:
name: Release
runs-on: ubuntu-latest
needs: nix-check
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout Repo
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Install Nix
uses: cachix/install-nix-action@v31
with:
github_access_token: ${{ secrets.GITHUB_TOKEN }}
- name: Install Dependencies
run: nix develop .#full --command pnpm install --frozen-lockfile
- name: Create Release Pull Request or Publish
id: changesets
uses: changesets/action@v1
with:
version: nix develop .#full --command pnpm run version
publish: nix develop .#full --command pnpm run release
commit: 'feat(release): version UTRP'
title: 'feat(release): version UTRP'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SENTRY_ORG: longhorn-developers
SENTRY_PROJECT: ut-registration-plus
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: Get UTRP Version and Tag
if: steps.changesets.outputs.hasChangesets == 'false'
id: version
run: |
VERSION=$(nix develop --command node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tag=v$VERSION" >> $GITHUB_OUTPUT
- name: Create GitHub Release with Assets
if: steps.changesets.outputs.hasChangesets == 'false'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.tag }}
name: Release ${{ steps.version.outputs.tag }}
files: |
package/ut-registration-plus-${{ steps.version.outputs.version }}.zip
generate_release_notes: true
draft: false
prerelease: false
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -13,9 +13,9 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v4
with: with:
version: 9 version: 10
- name: Install dependencies - name: Install dependencies
run: pnpm install run: pnpm install

2
.gitignore vendored
View File

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

View File

@@ -1,37 +0,0 @@
{
"branches": [
"production",
{
"name": "preview",
"channel": "alpha",
"prerelease": "alpha"
}
],
"plugins": [
[
"@semantic-release/commit-analyzer",
{
"preset": "conventionalcommits"
}
],
[
"@semantic-release/release-notes-generator",
{
"preset": "conventionalcommits"
}
],
[
"@semantic-release/exec",
{
"prepareCmd": "SEMANTIC_VERSION=${nextRelease.version} npm run build"
}
],
[
"@semantic-release/github",
{
"assets": "build/**/artifacts/*.*",
"failComment": false
}
]
]
}

View File

@@ -1,3 +1,66 @@
## [2.2.2](https://github.com/Longhorn-Developers/UT-Registration-Plus/compare/v2.2.1...v2.2.2) (2025-10-13)
### Features
- add nix flake ([#593](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/593)) ([7b401ad](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/7b401add1565ff401bad99745ff9e53b9a7f899f))
- automatically select new or duplicated schedules ([#583](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/583)) ([#589](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/589)) ([2a50f55](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/2a50f5580d3dbeb0d66546c23cf29bbb37d80da2))
- **env:** add SENTRY env vars ([8f7e1bc](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/8f7e1bc0af6336549068e02b80df21d4e8f4ef9c))
- export schedule button add to calendar ([#594](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/594)) ([5994ded](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/5994ded8be876cb55174d27d3fdb0832b21a0ff9))
- **release:** v2.2.2 ([c21cbd7](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/c21cbd77f0764c03a711589ff4f957cb8c936eec))
- search result shading ([#617](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/617)) ([be861b8](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/be861b823cb2cb7f6f4a1f266351eec3fc1c2f99))
- show warning for courses of different semesters ([#570](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/570)) ([2e7dac1](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/2e7dac1e3eba757231ac07ac966231c08c703a16))
- support summer grades, fix summer course parser ([#596](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/596)) ([2d92dd4](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/2d92dd47f00a44b7d48e92a8ffba94480e4e73f9))
### Bug Fixes
- fix or ignore various eslint warning ([#609](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/609)) ([95de8df](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/95de8df37243b6d59625df515a60442f11b7a9d3))
- limit height of schedule list dropdown in the extension popup ([#543](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/543)) ([eb8141e](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/eb8141ee8c3d32bce901457178d50781b78f86dd))
- whitespace wrapping in semester warning ([#629](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/629)) ([46fe591](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/46fe591fa72ef017eea7cfb8aa37d12d8f223926))
## [2.2.1](https://github.com/Longhorn-Developers/UT-Registration-Plus/compare/v2.2.0...v2.2.1) (2025-06-04)
### Features
- add dining app promo ([#598](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/598)) ([be1dccf](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/be1dccfcb9d052c6b291b50cc53418d6bb645beb))
- inside jokes005 ([#590](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/590)) ([37471ef](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/37471efb740c7a5828cf3b54bac70954694359d7))
- **release:** v2.2.1 ([234f3d6](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/234f3d627d603adf8555b4d0e93106d198918169))
### Bug Fixes
- course columns on calendar ([#587](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/587)) ([cfb5faa](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/cfb5faa09bb0788e270d100f1f36536a53bcff75))
- hide sentry instrumentation on debug builds ([#604](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/604)) ([454e5e8](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/454e5e807af29ae0384cc3a3b8b691df5edc69d1))
## [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))
- **release:** v2.2.0 ([7a4f40a](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/7a4f40a765d704bf32a3b515d695916ed84f9397))
- 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
- add isDeveloper ([c6452c4](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/c6452c4f2b174487e6f51ad546ca1c3f8b4dbc1f))
- map page ([#390](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/390)) ([2184774](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/218477404fdeacda7b39cd233e4e1e65995935d4))
### Bug Fixes
- import schedule file upload button ([#515](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/515)) ([766c0bc](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/766c0bc1b4a75f8146a922cb2eca8871032c1dc9))
## [2.1.0](https://github.com/Longhorn-Developers/UT-Registration-Plus/compare/v2.0.2...v2.1.0) (2025-02-20) ## [2.1.0](https://github.com/Longhorn-Developers/UT-Registration-Plus/compare/v2.0.2...v2.1.0) (2025-02-20)
### Features ### Features

View File

@@ -2,7 +2,7 @@
FROM node:20.9.0-alpine AS base FROM node:20.9.0-alpine AS base
# Install pnpm # Install pnpm
RUN npm install -g pnpm RUN npm install -g pnpm@latest-10
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app

View File

@@ -26,8 +26,9 @@
## Toolchain ## Toolchain
- React v20.9.0 (LTS) - React v20.9.0 (LTS)
- TypeScript - TypeScript v5.x
- Vite 5 - Vite v5.x
- pnpm v10.x
- UnoCSS - UnoCSS
- ESLint - ESLint
- Prettier - Prettier
@@ -184,8 +185,9 @@ We maintain a strict code of conduct. By contributing, you agree to adhere to th
Special thanks to the developers and contributors behind these amazing tools and libraries: Special thanks to the developers and contributors behind these amazing tools and libraries:
- React v20.9.0 (LTS) - React v20.9.0 (LTS)
- TypeScript - TypeScript v5.x
- Vite 5 - Vite v5.x
- pnpm v10.x
- UnoCSS - UnoCSS
- ESLint - ESLint
- Prettier - Prettier

61
flake.lock generated Normal file
View File

@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1759831965,
"narHash": "sha256-vgPm2xjOmKdZ0xKA6yLXPJpjOtQPHfaZDRtH+47XEBo=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c9b6fb798541223bbb396d287d16f43520250518",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

43
flake.nix Normal file
View File

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

View File

@@ -35,9 +35,13 @@ function removeExtraDatabaseDir(cb) {
// Instrument with Sentry // Instrument with Sentry
// Make sure sentry is configured https://docs.sentry.io/platforms/javascript/sourcemaps/uploading/typescript/#2-configure-sentry-cli // Make sure sentry is configured https://docs.sentry.io/platforms/javascript/sourcemaps/uploading/typescript/#2-configure-sentry-cli
async function instrumentWithSentry(cb) { async function instrumentWithSentry(cb) {
await exec(`sentry-cli sourcemaps inject ${DIST_DIR}`); if (process.env.SENTRY_ENV && process.env.SENTRY_ENV !== 'development') {
await exec(`sentry-cli sourcemaps upload ${DIST_DIR}`); await exec(`sentry-cli sourcemaps inject ${DIST_DIR}`);
log('Sentry instrumentation completed.'); await exec(`sentry-cli sourcemaps upload ${DIST_DIR}`);
log('Sentry instrumentation completed.');
} else {
logWarn('Skipping uploading/creating Sentry source maps. (development build)');
}
cb(); cb();
} }

View File

@@ -1,7 +1,7 @@
{ {
"name": "ut-registration-plus", "name": "ut-registration-plus",
"displayName": "UT Registration Plus", "displayName": "UT Registration Plus",
"version": "2.1.0", "version": "2.2.2",
"description": "UT Registration Plus is a Chrome extension that allows students to easily register for classes.", "description": "UT Registration Plus is a Chrome extension that allows students to easily register for classes.",
"private": true, "private": true,
"homepage": "https://github.com/Longhorn-Developers/UT-Registration-Plus", "homepage": "https://github.com/Longhorn-Developers/UT-Registration-Plus",
@@ -9,8 +9,13 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"build:dev": "tsc && NODE_ENV='development' vite build --mode development",
"build:watch": "NODE_ENV='development' vite build --mode development -w", "build:watch": "NODE_ENV='development' vite build --mode development -w",
"zip": "PROD=true pnpm build && pnpm gulp zipProdBuild", "zip": "pnpm build && pnpm gulp zipProdBuild",
"zip:to-publish": "SENTRY_ENV='production' pnpm zip",
"changeset": "changeset",
"version": "changeset version && pnpm run generate-changelog",
"release": "pnpm run zip:to-publish && changeset tag",
"prettier": "prettier src --check", "prettier": "prettier src --check",
"prettier:fix": "prettier src --write", "prettier:fix": "prettier src --write",
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives", "lint": "eslint src --ext ts,tsx --report-unused-disable-directives",
@@ -27,66 +32,70 @@
"prepare": "husky" "prepare": "husky"
}, },
"dependencies": { "dependencies": {
"@date-fns/tz": "^1.2.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@headlessui/react": "^2.2.0", "@headlessui/react": "^2.2.0",
"@octokit/rest": "^21.0.2", "@octokit/rest": "^21.1.1",
"@phosphor-icons/react": "^2.1.7", "@phosphor-icons/react": "^2.1.7",
"@sentry/react": "^8.38.0", "@sentry/react": "^8.55.0",
"@tanstack/react-query": "^5.69.0",
"@unocss/vite": "^0.63.6", "@unocss/vite": "^0.63.6",
"@vitejs/plugin-react": "^4.3.3", "@vitejs/plugin-react": "^4.3.4",
"chrome-extension-toolkit": "^0.0.54", "chrome-extension-toolkit": "^0.0.54",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"conventional-changelog": "^6.0.0", "conventional-changelog": "^6.0.0",
"date-fns": "^4.1.0",
"highcharts": "^11.4.8", "highcharts": "^11.4.8",
"highcharts-react-official": "^3.2.1", "highcharts-react-official": "^3.2.1",
"html-to-image": "^1.11.11", "html-to-image": "^1.11.13",
"husky": "^9.1.6", "husky": "^9.1.7",
"kc-dabr-wasm": "^0.1.2", "kc-dabr-wasm": "^0.1.2",
"nanoid": "^5.0.8", "nanoid": "^5.1.2",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-loading-skeleton": "^3.5.0", "react-loading-skeleton": "^3.5.0",
"react-markdown": "^9.0.1", "react-markdown": "^9.1.0",
"react-syntax-highlighter": "^15.6.1", "react-syntax-highlighter": "^15.6.1",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.1",
"sass": "^1.81.0", "sass": "^1.85.1",
"simple-git": "^3.27.0", "simple-git": "^3.27.0",
"sql.js": "1.11.0" "sql.js": "1.11.0"
}, },
"devDependencies": { "devDependencies": {
"@changesets/cli": "^2.29.7",
"@chromatic-com/storybook": "^2.0.2", "@chromatic-com/storybook": "^2.0.2",
"@commitlint/cli": "^19.5.0", "@commitlint/cli": "^19.7.1",
"@commitlint/config-conventional": "^19.5.0", "@commitlint/config-conventional": "^19.7.1",
"@commitlint/types": "^19.5.0", "@commitlint/types": "^19.5.0",
"@crxjs/vite-plugin": "2.0.0-beta.21", "@crxjs/vite-plugin": "2.0.0-beta.21",
"@iconify-json/bi": "^1.2.1", "@iconify-json/bi": "^1.2.2",
"@iconify-json/ic": "^1.2.1", "@iconify-json/ic": "^1.2.2",
"@iconify-json/iconoir": "^1.2.4", "@iconify-json/iconoir": "^1.2.7",
"@iconify-json/material-symbols": "^1.2.6", "@iconify-json/material-symbols": "^1.2.14",
"@iconify-json/ri": "^1.2.3", "@iconify-json/ri": "^1.2.5",
"@iconify-json/streamline": "^1.2.1", "@iconify-json/streamline": "^1.2.2",
"@semantic-release/exec": "^6.0.3", "@semantic-release/exec": "^6.0.3",
"@sentry/types": "^8.38.0", "@sentry/types": "^8.55.0",
"@storybook/addon-designs": "^8.0.4", "@storybook/addon-designs": "^8.2.0",
"@storybook/addon-essentials": "^8.4.4", "@storybook/addon-essentials": "^8.6.0",
"@storybook/addon-links": "^8.4.4", "@storybook/addon-links": "^8.6.0",
"@storybook/blocks": "^8.4.4", "@storybook/blocks": "^8.6.0",
"@storybook/react": "^8.4.4", "@storybook/react": "^8.6.0",
"@storybook/react-vite": "^8.4.4", "@storybook/react-vite": "^8.6.0",
"@storybook/test": "^8.4.4", "@storybook/test": "^8.6.0",
"@svgr/core": "^8.1.0", "@svgr/core": "^8.1.0",
"@svgr/plugin-jsx": "^8.1.0", "@svgr/plugin-jsx": "^8.1.0",
"@types/chrome": "^0.0.273", "@types/chrome": "^0.0.273",
"@types/conventional-changelog": "^3.1.5", "@types/conventional-changelog": "^3.1.5",
"@types/gulp": "^4.0.17", "@types/gulp": "^4.0.17",
"@types/gulp-zip": "^4.0.4", "@types/gulp-zip": "^4.0.4",
"@types/node": "^22.9.0", "@types/node": "^22.13.5",
"@types/prompts": "^2.4.9", "@types/prompts": "^2.4.9",
"@types/react": "^18.3.12", "@types/react": "^18.3.18",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.5",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@types/semantic-release": "^20.0.6", "@types/semantic-release": "^20.0.6",
"@types/semver": "^7.5.8", "@types/semver": "^7.5.8",
@@ -100,48 +109,52 @@
"@unocss/reset": "^0.63.6", "@unocss/reset": "^0.63.6",
"@unocss/transformer-directives": "^0.63.6", "@unocss/transformer-directives": "^0.63.6",
"@unocss/transformer-variant-group": "^0.63.6", "@unocss/transformer-variant-group": "^0.63.6",
"@vitejs/plugin-react-swc": "^3.7.1", "@vitejs/plugin-react-swc": "^3.8.0",
"@vitest/coverage-v8": "^2.1.5", "@vitest/coverage-v8": "^2.1.9",
"@vitest/ui": "^2.1.5", "@vitest/ui": "^2.1.9",
"chalk": "^5.3.0", "chalk": "^5.4.1",
"chromatic": "^11.18.1", "chromatic": "^11.26.0",
"cssnano": "^7.0.6", "cssnano": "^7.0.6",
"cssnano-preset-advanced": "^7.0.6", "cssnano-preset-advanced": "^7.0.6",
"dotenv": "^16.4.5", "dotenv": "^16.4.7",
"es-module-lexer": "^1.5.4", "es-module-lexer": "^1.6.0",
"eslint": "^8.57.1", "eslint": "^8.57.1",
"eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-base": "^15.0.0",
"eslint-config-airbnb-typescript": "^18.0.0", "eslint-config-airbnb-typescript": "^18.0.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^3.6.3", "eslint-import-resolver-typescript": "^3.8.3",
"eslint-plugin-import": "^2.31.0", "eslint-plugin-import": "^2.31.0",
"eslint-plugin-import-essentials": "^0.2.1", "eslint-plugin-import-essentials": "^0.2.1",
"eslint-plugin-jsdoc": "^50.5.0", "eslint-plugin-jsdoc": "^50.6.3",
"eslint-plugin-prettier": "^5.2.1", "eslint-plugin-prettier": "^5.2.3",
"eslint-plugin-react": "^7.37.2", "eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-prefer-function-component": "^3.3.0", "eslint-plugin-react-prefer-function-component": "^3.4.0",
"eslint-plugin-react-refresh": "^0.4.14", "eslint-plugin-react-refresh": "^0.4.19",
"eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-storybook": "^0.9.0", "eslint-plugin-storybook": "^0.9.0",
"eslint-plugin-tsdoc": "^0.3.0", "eslint-plugin-tsdoc": "^0.3.0",
"gulp": "^5.0.0", "gulp": "^5.0.0",
"gulp-execa": "^7.0.1", "gulp-execa": "^7.0.1",
"gulp-zip": "^6.0.0", "gulp-zip": "^6.1.0",
"path": "^0.12.7", "path": "^0.12.7",
"postcss": "^8.4.49", "postcss": "^8.5.3",
"prettier": "^3.3.3", "prettier": "^3.5.2",
"react-dev-utils": "^12.0.1", "react-dev-utils": "^12.0.1",
"semantic-release": "^24.2.0", "semantic-release": "^24.2.3",
"storybook": "^8.4.4", "storybook": "^8.6.0",
"typescript": "^5.6.3", "typescript": "^5.7.3",
"unocss": "^0.63.6", "unocss": "^0.63.6",
"unocss-preset-primitives": "0.0.2-beta.1", "unocss-preset-primitives": "0.0.2-beta.1",
"unplugin-icons": "^0.19.3", "unplugin-icons": "^0.19.3",
"vite": "^5.4.11", "vite": "^5.4.20",
"vite-plugin-inspect": "^0.8.7", "vite-plugin-inspect": "^0.8.9",
"vitest": "^2.1.5" "vitest": "^2.1.9"
},
"engineStrict": true,
"engines": {
"pnpm": "^10"
}, },
"pnpm": { "pnpm": {
"patchedDependencies": { "patchedDependencies": {
@@ -151,5 +164,9 @@
"overrides": { "overrides": {
"es-module-lexer": "^1.5.4" "es-module-lexer": "^1.5.4"
} }
},
"volta": {
"node": "20.19.4",
"pnpm": "10.14.0"
} }
} }

3247
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -27,10 +27,10 @@ interface Props {
/** /**
* Generates a changelog using the conventional-changelog command. * Generates a changelog using the conventional-changelog command.
* *
* @returns {Promise<void>} A promise that resolves when the changelog is generated. * @returns A promise that resolves when the changelog is generated.
* @throws {Error} If there is an error generating the changelog. * @throws If there is an error generating the changelog.
*/ */
async function generateChangelog({ preset, outFile = 'CHANGELOG.md', releaseCount = 1 }: Props): Promise<void> { async function generateChangelog({ preset, outFile = 'CHANGELOG-local.md', releaseCount = 1 }: Props): Promise<void> {
try { try {
// Run the conventional-changelog command to generate changelog // Run the conventional-changelog command to generate changelog
const { stdout, stderr } = await execPromise( const { stdout, stderr } = await execPromise(

View File

@@ -1,53 +0,0 @@
import prompts from 'prompts';
import { simpleGit } from 'simple-git';
import { getSourceRef } from '../utils/git/getSourceRef';
import { error, info } from '../utils/log';
const git = simpleGit();
const status = await git.status();
if (status.files.length) {
console.log(error('Working directory is not clean, please commit or stash changes before releasing.'));
process.exit(1);
}
const { destinationBranch } = await prompts({
type: 'select',
name: 'destinationBranch',
message: 'What kind of release do you want to create?',
choices: ['preview', 'production'].map(releaseType => ({
title: releaseType,
value: releaseType,
})),
});
const sourceRef = await getSourceRef(destinationBranch);
const { confirm } = await prompts({
type: 'confirm',
name: 'confirm',
message: `Are you sure you want to create a ${destinationBranch} release from ${sourceRef}?`,
});
if (!confirm) {
console.log(error('Aborting release.'));
process.exit(1);
}
// we fetch the latest changes from the remote
await git.fetch();
// we checkout the source ref, pull the latest changes and then checkout the destination branch
console.info(`Checking out and updating ${sourceRef}...`);
await git.checkout(sourceRef);
await git.pull('origin', sourceRef);
console.info(`Checking out and updating ${destinationBranch}...`);
await git.checkout(destinationBranch);
await git.pull('origin', destinationBranch);
// we trigger the release github action by merging the source ref into the destination branch
console.info(`Merging ${sourceRef} into ${destinationBranch}...`);
await git.merge([sourceRef, '--ff-only']);
await git.push('origin', destinationBranch);
console.info(info(`Release to ${destinationBranch} created! Check github for status`));

BIN
src/assets/LD-icon-new.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

5075
src/assets/UT-Map.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 347 KiB

View File

@@ -8,15 +8,27 @@ const splashText: string[] = [
'The squirrels, they have mastered begging for food better than students. Impressive... but worrying.', 'The squirrels, they have mastered begging for food better than students. Impressive... but worrying.',
"Do you study often? Ha! What am I saying? Of course you don't.", "Do you study often? Ha! What am I saying? Of course you don't.",
"Hey, you, you're finally awake. You were trying to skip class right?", "Hey, you, you're finally awake. You were trying to skip class right?",
'Mmm... Brutalist architecture...',
'The course syllabus: more than meets the eye',
'Pain is temporary, GPA is forever.',
"You've Yee'd Your Last Haw.",
'lol everything is already waitlisted.',
'Could be worse. Could be A&M.',
"Should you major in Compsci? well, here's a better question. do you wanna have a bad time?",
'A pen and paper is old fashioned, but sometimes old ways are best',
'A heart is like bedrock, destroyable only by cheating',
'You may not rest now, there are Canvas assignments nearby',
'You are filled with DETERMINATION',
'60k+ users!',
'Almost Turing complete',
'#BF5700',
'The waitlist is a lie!',
'BEVO JOCKEY 🗣️🗣️🗣️',
'RIP Domino, you beloved campus feline.', 'RIP Domino, you beloved campus feline.',
"The year is 2055 and Welch still isn't finished.", "The year is 2055 and Welch still isn't finished.",
'Motivation dropping faster than ur GPA', 'Motivation dropping faster than ur GPA',
'No Work Happens On PCL 5th Floor.', 'No Work Happens On PCL 5th Floor.',
'I may be a sophomore in name, but my credit count screams freshman!', 'I may be a sophomore in name, but my credit count screams freshman!',
'Pain is temporary, GPA is forever.',
"You've Yee'd Your Last Haw.",
'lol everything is already waitlisted.',
'Could be worse. Could be A&M.',
// 'TeXAs iS BaCK GuYZ', // 'TeXAs iS BaCK GuYZ',
'mAke iT yOuR tExAS', 'mAke iT yOuR tExAS',
'change yOur slogan', 'change yOur slogan',
@@ -24,7 +36,7 @@ const splashText: string[] = [
'Does McCombs teach Parseltongue?', 'Does McCombs teach Parseltongue?',
'No Cruce Enfrente Del Bus.', 'No Cruce Enfrente Del Bus.',
'Omae Wa Mou Shindeiru...', 'Omae Wa Mou Shindeiru...',
'Every day another brick disappears from Speedway', "They say each day, another brick disappears from Speedway. No one's sure where to.",
'The GDC will annex the EER one day', 'The GDC will annex the EER one day',
'To hike to Kins or not to hike to Kins...', 'To hike to Kins or not to hike to Kins...',
"C'mon you Longhorns! You want to study forever?", "C'mon you Longhorns! You want to study forever?",
@@ -34,15 +46,11 @@ const splashText: string[] = [
"The Block of Butter incident of '22", "The Block of Butter incident of '22",
'Begun, the midterms have.', 'Begun, the midterms have.',
'You must construct additional schedules', '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', 'THE WALK SIGN IS ON TO CROSS GUADALUPE AND 21ST',
'Pay attention. Might learn something.', '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!', 'Roll for Initiative!',
'The line at the on-campus Starbucks is longer than your course waitlist.', 'The line at the on-campus Starbucks is longer than your course waitlist.',
'The weather changes more often than your class schedule.', 'The weather changes more often than your class schedule.',
'Mmm... Brutalist architecture...',
'The course syllabus: more than meets the eye',
"'studying' often means refreshing Canvas every five minutes to see if the professor posted lecture slides.", "'studying' often means refreshing Canvas every five minutes to see if the professor posted lecture slides.",
"It's over Bevo! I have the high ground!", "It's over Bevo! I have the high ground!",
"I'll just skip this lecture and watch the recording later. What's the worst that could happen?", "I'll just skip this lecture and watch the recording later. What's the worst that could happen?",
@@ -59,23 +67,15 @@ const splashText: string[] = [
'follow @sghsri!', 'follow @sghsri!',
'Officially part of the SEC', 'Officially part of the SEC',
'Planner is now acquired by Plus', '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', 'The Eiffel Tower is the UT Tower of Paris',
'A pen and paper is old fashioned, but sometimes old ways are best', "He's a CS Major, but he showers regularly. 🧢",
'A heart is like bedrock, destroyable only by cheating',
'You may not rest now, there are Canvas assignments nearby',
'You are filled with DETERMINATION',
'60k+ users!',
'Almost Turing complete',
'#BF5700',
'The waitlist is a lie!',
`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.', '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 - 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 - 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?', 'UT Registration Plus - Do you really want to figure out which professors will ruin your GPA by yourself?',
'Ayo tf is a memory leak', "Ayo what's is a memory leak",
"lowkey we never thought we'd get this far, how tf are 60k of you people using this", "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", "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'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!", "We've only caused one or two outages, we swear!",
@@ -113,8 +113,7 @@ const splashText: string[] = [
"Stop trying to make UTRP happen, it's not going to happen!", "Stop trying to make UTRP happen, it's not going to happen!",
'Befriend the raccoons on campus', 'Befriend the raccoons on campus',
`It's ${new Date().toLocaleString('en-US', { month: 'long', day: 'numeric' })} and OU still sucks`, `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?",
]; ];
export default splashText; export default splashText;

View File

@@ -1,4 +1,7 @@
import { ExtensionStore } from '@shared/storage/ExtensionStore'; 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) * 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, version: chrome.runtime.getManifest().version,
lastUpdate: Date.now(), 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,14 @@ export default async function createSchedule(scheduleName: string) {
schedules.push(newSchedule); schedules.push(newSchedule);
await UserScheduleStore.set('schedules', schedules); await UserScheduleStore.set('schedules', schedules);
// Automatically switch to the new schedule
await UserScheduleStore.set('activeIndex', schedules.length - 1);
// If there is only one schedule, set the active index to the new schedule
if (schedules.length <= 1) {
await UserScheduleStore.set('activeIndex', 0);
}
return newSchedule.id; return newSchedule.id;
} }

View File

@@ -22,6 +22,11 @@ export default async function deleteSchedule(scheduleId: string): Promise<string
schedules.splice(scheduleIndex, 1); schedules.splice(scheduleIndex, 1);
await UserScheduleStore.set('schedules', schedules); 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; let newActiveIndex = activeIndex;
if (scheduleIndex < activeIndex) { if (scheduleIndex < activeIndex) {
newActiveIndex = activeIndex - 1; newActiveIndex = activeIndex - 1;

View File

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

View File

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

View File

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

19
src/pages/map/Map.tsx Normal file
View File

@@ -0,0 +1,19 @@
import DialogProvider from '@views/components/common/DialogProvider/DialogProvider';
import ExtensionRoot from '@views/components/common/ExtensionRoot/ExtensionRoot';
import Map from '@views/components/map/Map';
import useKC_DABR_WASM from 'kc-dabr-wasm';
import React from 'react';
/**
* Renders the map page for the UTRP (UT Registration Plus) extension.
*/
export default function MapPage() {
useKC_DABR_WASM();
return (
<ExtensionRoot>
<DialogProvider>
<Map />
</DialogProvider>
</ExtensionRoot>
);
}

16
src/pages/map/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<title>UTRP Map</title>
</head>
<body style="min-height: 100vh; height: 0; margin: 0">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="./index.tsx" type="module"></script>
</body>
</html>

6
src/pages/map/index.tsx Normal file
View File

@@ -0,0 +1,6 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import Map from './Map';
createRoot(document.getElementById('root')!).render(<Map />);

View File

@@ -4,6 +4,8 @@ import { createLocalStore, debugStore } from 'chrome-extension-toolkit';
* A store that is used to store data that is only relevant during development * A store that is used to store data that is only relevant during development
*/ */
interface IDevStore { interface IDevStore {
/** whether the user is a developer */
isDeveloper: boolean;
/** the tabId for the debug tab */ /** the tabId for the debug tab */
debugTabId?: number; debugTabId?: number;
/** whether the debug tab is visible */ /** whether the debug tab is visible */
@@ -17,6 +19,7 @@ interface IDevStore {
} }
export const DevStore = createLocalStore<IDevStore>({ export const DevStore = createLocalStore<IDevStore>({
isDeveloper: false,
debugTabId: undefined, debugTabId: undefined,
isTabReloading: true, isTabReloading: true,
wasDebugTabVisible: false, wasDebugTabVisible: false,

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ export const CRX_PAGES = {
DEBUG: '/debug.html', DEBUG: '/debug.html',
CALENDAR: '/calendar.html', CALENDAR: '/calendar.html',
OPTIONS: '/options.html', OPTIONS: '/options.html',
MAP: '/map.html',
REPORT: '/report.html', REPORT: '/report.html',
} as const; } as const;

View File

@@ -44,7 +44,12 @@ export type Semester = {
export class Course { export class Course {
/** Every course has a uniqueId within UT's registrar system corresponding to each course section */ /** Every course has a uniqueId within UT's registrar system corresponding to each course section */
uniqueId!: number; uniqueId!: number;
/** This is the course number for a course, i.e CS 314 would be 314, MAL 306H would be 306H */ /**
* This is the course number for a course, i.e CS 314 would be 314, MAL 306H would be 306H.
* UT prefixes summer courses with f, s, n, or w:
* [f]irst term, [s]econd term, [n]ine week term, [w]hole term.
* So, the first term of PSY 301 over the summer would be 'f301'
*/
number!: string; number!: string;
/** The full name of the course, i.e. CS 314 Data Structures and Algorithms */ /** The full name of the course, i.e. CS 314 Data Structures and Algorithms */
fullName!: string; fullName!: string;
@@ -91,6 +96,46 @@ export class Course {
} }
this.colors = course.colors ? structuredClone(course.colors) : getCourseColors('emerald', 500); this.colors = course.colors ? structuredClone(course.colors) : getCourseColors('emerald', 500);
this.core = course.core ?? []; this.core = course.core ?? [];
if (course.semester.season === 'Summer') {
// A bug from and old version put the summer term in the course,
// so we need to handle that case
const { department, number } = Course.cleanSummerTerm(course.department, course.number);
this.department = department;
this.number = number;
}
}
/**
* Due to a bug in an older version, the summer term was included in the course department code,
* instead of the course number.
* UT prefixes summer courses with f, s, n, or w:
* [f]irst term, [s]econd term, [n]ine week term, [w]hole term
*
* @param department - The course department code, like 'C S'
* @param number - The course number, like '314H'
* @returns The properly formatted department and course number
* @example
* ```ts
* cleanSummerTerm('C S', '314H') // { department: 'C S', number: '314H' }
* cleanSummerTerm('P R', 'f378') // { department: 'P R', number: 'f378' }
* cleanSummerTerm('P R f', '378') // { department: 'P R', number: 'f378' }
* cleanSummerTerm('P S', 'n303') // { department: 'P S', number: 'n303' }
* cleanSummerTerm('P S n', '303') // { department: 'P S', number: 'n303' }
* ```
*/
static cleanSummerTerm(department: string, number: string): { department: string; number: string } {
// UT prefixes summer courses with f, s, n, or w:
// [f]irst term, [s]econd term, [n]ine week term, [w]hole term
const summerTerm = department.match(/[fsnw]$/);
if (!summerTerm) {
return { department, number };
}
return {
department: department.slice(0, -1).trim(),
number: summerTerm[0] + number,
};
} }
/** /**
@@ -111,6 +156,18 @@ export class Course {
return conflicts; return conflicts;
} }
/**
* @returns The course number without the summer term
* @example
* ```ts
* const c = new Course({ number: 'f301', ... });
* c.getNumberWithoutTerm() // '301'
* ```
*/
getNumberWithoutTerm(): string {
return this.number.replace(/^\D/, ''); // Remove nondigit at start, if it exists
}
} }
/** /**

View File

@@ -17,4 +17,9 @@ const MIMEType = {
*/ */
export type MIMETypeKey = keyof typeof MIMEType; export type MIMETypeKey = keyof typeof MIMEType;
/**
* Represents a value of the MIMEType object
*/
export type MIMETypeValue = (typeof MIMEType)[MIMETypeKey];
export default MIMEType; export default MIMEType;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { Course } from '../Course';
describe('Course::cleanSummerTerm', () => {
it("shouldn't affect already cleaned summer terms", () => {
const inputs = [
['C S', '314H'],
['P R', 'f378'],
['P S', 'f303'],
['WGS', 's301'],
['S W', 'n360K'],
['GOV', 'w312L'],
['J', 's311F'],
['J S', '311F'],
] as const;
const expected = [
{ department: 'C S', number: '314H' },
{ department: 'P R', number: 'f378' },
{ department: 'P S', number: 'f303' },
{ department: 'WGS', number: 's301' },
{ department: 'S W', number: 'n360K' },
{ department: 'GOV', number: 'w312L' },
{ department: 'J', number: 's311F' },
{ department: 'J S', number: '311F' },
];
const results = inputs.map(input => Course.cleanSummerTerm(input[0], input[1]));
expect(results).toEqual(expected);
});
it('should move summer term indicator to course number', () => {
const inputs = [
['P R f', '378'],
['P S f', '303'],
['WGS s', '301'],
['S W n', '360K'],
['GOV w', '312L'],
['J s', '311F'],
['J S', '311F'],
] as const;
const expected = [
{ department: 'P R', number: 'f378' },
{ department: 'P S', number: 'f303' },
{ department: 'WGS', number: 's301' },
{ department: 'S W', number: 'n360K' },
{ department: 'GOV', number: 'w312L' },
{ department: 'J', number: 's311F' },
{ department: 'J S', number: '311F' },
];
const results = inputs.map(input => Course.cleanSummerTerm(input[0], input[1]));
expect(results).toEqual(expected);
});
});

View File

@@ -0,0 +1,19 @@
/**
* This file contains URLs for external applications and resources.
* Centralizing these URLs makes it easier to track, update, and manage them in a single place.
*/
/**
* URL to the UT Dining app on the App Store
*/
export const UT_DINING_APP_STORE_URL = new URL('https://apps.apple.com/us/app/ut-dining/id6743042002').toString();
/**
* URL to the UT Dining app on the Google Play Store (currently not available)
*/
export const UT_DINING_GOOGLE_PLAY_URL = ''; // Placeholder for Google Play URL, Android app not available yet
/**
* URL to the promo image
*/
export const UT_DINING_PROMO_IMAGE_URL = new URL('https://cdn.longhorns.dev/ut-dining-advert1.png').toString();

View File

@@ -48,3 +48,22 @@ export const ellipsify = (input: string, chars: number): string => {
} }
return ellipisifed; 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'; import { describe, expect, it } from 'vitest';
// TODO: Fix `string.ts` and `string.test.ts` to make the tests pass // TODO: Fix `string.ts` and `string.test.ts` to make the tests pass
@@ -54,3 +54,49 @@ describe('ellipsify', () => {
expect(ellipsify('', 5)).toBe(''); 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', () => { describe('sleep', () => {
it('should resolve after the specified number of milliseconds', async () => { it('should resolve after the specified number of milliseconds', async () => {
const start = Date.now(); const start = performance.now();
const milliseconds = 1000; const milliseconds = 1000;
await sleep(milliseconds); await sleep(milliseconds);
const end = Date.now(); const end = performance.now();
const elapsed = end - start; 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

@@ -0,0 +1,14 @@
import type { Meta, StoryObj } from '@storybook/react';
import DiningAppPromo from '@views/components/calendar/DiningAppPromo';
export default {
title: 'Components/Calendar/DiningAppPromo',
component: DiningAppPromo,
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof DiningAppPromo>;
type Story = StoryObj<typeof DiningAppPromo>;
export const Default: Story = {};

View File

@@ -73,7 +73,7 @@ const generateCourses = (count: number): Course[] => {
const exampleCourses = generateCourses(numberOfCourses); const exampleCourses = generateCourses(numberOfCourses);
type CourseWithId = Course & BaseItem; type CourseWithId = { course: Course } & BaseItem;
const meta = { const meta = {
title: 'Components/Common/SortableList', title: 'Components/Common/SortableList',
@@ -91,11 +91,10 @@ export const Default: Story = {
args: { args: {
draggables: exampleCourses.map(course => ({ draggables: exampleCourses.map(course => ({
id: course.uniqueId, id: course.uniqueId,
...course, course,
getConflicts: course.getConflicts,
})), })),
onChange: () => {}, onChange: () => {},
renderItem: course => <PopupCourseBlock key={course.id} course={course} colors={course.colors} />, renderItem: ({ id, course }) => <PopupCourseBlock key={id} course={course} colors={course.colors} />,
}, },
render: args => ( render: args => (
<div className='h-3xl w-3xl transform-none'> <div className='h-3xl w-3xl transform-none'>

View File

@@ -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, hours: 3,
updatedAt: Date.now(), 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,9 @@ import { CourseCatalogScraper } from '@views/lib/CourseCatalogScraper';
import getCourseTableRows from '@views/lib/getCourseTableRows'; import getCourseTableRows from '@views/lib/getCourseTableRows';
import type { SiteSupportType } from '@views/lib/getSiteSupport'; import type { SiteSupportType } from '@views/lib/getSiteSupport';
import { populateSearchInputs } from '@views/lib/populateSearchInputs'; import { populateSearchInputs } from '@views/lib/populateSearchInputs';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import DialogProvider from './common/DialogProvider/DialogProvider';
interface Props { interface Props {
support: Extract<SiteSupportType, 'COURSE_CATALOG_DETAILS' | 'COURSE_CATALOG_LIST'>; support: Extract<SiteSupportType, 'COURSE_CATALOG_DETAILS' | 'COURSE_CATALOG_LIST'>;
@@ -27,6 +29,8 @@ export default function CourseCatalogMain({ support }: Props): JSX.Element | nul
const [selectedCourse, setSelectedCourse] = useState<Course | null>(null); const [selectedCourse, setSelectedCourse] = useState<Course | null>(null);
const [showPopup, setShowPopup] = useState(false); const [showPopup, setShowPopup] = useState(false);
const [enableScrollToLoad, setEnableScrollToLoad] = useState<boolean>(false); const [enableScrollToLoad, setEnableScrollToLoad] = useState<boolean>(false);
const prevCourseTitleRef = useRef<string | null>(null);
const tbody = document.querySelector('table tbody')!;
useEffect(() => { useEffect(() => {
populateSearchInputs(); populateSearchInputs();
@@ -43,6 +47,9 @@ export default function CourseCatalogMain({ support }: Props): JSX.Element | nul
const ccs = new CourseCatalogScraper(support); const ccs = new CourseCatalogScraper(support);
const scrapedRows = ccs.scrape(tableRows, true); const scrapedRows = ccs.scrape(tableRows, true);
setRows(scrapedRows); setRows(scrapedRows);
prevCourseTitleRef.current =
scrapedRows.findLast(row => row.course === null)?.element.querySelector('.course_header')?.textContent ??
null;
}, [support]); }, [support]);
useEffect(() => { useEffect(() => {
@@ -51,8 +58,17 @@ export default function CourseCatalogMain({ support }: Props): JSX.Element | nul
const addRows = (newRows: ScrapedRow[]) => { const addRows = (newRows: ScrapedRow[]) => {
newRows.forEach(row => { 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]); setRows([...rows, ...newRows]);
}; };
@@ -68,28 +84,30 @@ export default function CourseCatalogMain({ support }: Props): JSX.Element | nul
return ( return (
<ExtensionRoot> <ExtensionRoot>
<NewSearchLink /> <DialogProvider>
<RecruitmentBanner /> <NewSearchLink />
<TableHead>Plus</TableHead> <RecruitmentBanner />
{rows.map( <TableHead>Plus</TableHead>
row => {rows.map(
row.course && ( row =>
<TableRow row.course && (
key={row.course.uniqueId} <TableRow
row={row} key={row.course.uniqueId}
isSelected={row.course.uniqueId === selectedCourse?.uniqueId} row={row}
activeSchedule={activeSchedule} isSelected={row.course.uniqueId === selectedCourse?.uniqueId}
onClick={handleRowButtonClick(row.course)} activeSchedule={activeSchedule}
/> onClick={handleRowButtonClick(row.course)}
) />
)} )
<CourseCatalogInjectedPopup )}
course={selectedCourse!} // always defined when showPopup is true <CourseCatalogInjectedPopup
show={showPopup} course={selectedCourse!} // always defined when showPopup is true
onClose={() => setShowPopup(false)} show={showPopup}
afterLeave={() => setSelectedCourse(null)} onClose={() => setShowPopup(false)}
/> afterLeave={() => setSelectedCourse(null)}
{enableScrollToLoad && <AutoLoad addRows={addRows} />} />
{enableScrollToLoad && <AutoLoad addRows={addRows} />}
</DialogProvider>
</ExtensionRoot> </ExtensionRoot>
); );
} }

View File

@@ -66,11 +66,19 @@ export default function PopupMain(): JSX.Element {
}; };
useEffect(() => { useEffect(() => {
const randomIndex = Math.floor(Math.random() * splashText.length); setFunny(prevFunny => {
setFunny( // Ensure that the next splash text is not the same as the previous one
splashText[randomIndex] ?? 'If you are seeing this, something has gone horribly wrong behind the scenes.' 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 handleOpenOptions = async () => {
const url = chrome.runtime.getURL('/options.html'); const url = chrome.runtime.getURL('/options.html');
@@ -147,15 +155,14 @@ export default function PopupMain(): JSX.Element {
<SortableList <SortableList
draggables={activeSchedule.courses.map(course => ({ draggables={activeSchedule.courses.map(course => ({
id: course.uniqueId, id: course.uniqueId,
...course, course,
getConflicts: course.getConflicts,
}))} }))}
onChange={reordered => { onChange={reordered => {
activeSchedule.courses = reordered.map(({ id: _id, ...course }) => course); activeSchedule.courses = reordered.map(({ course }) => course);
replaceSchedule(getActiveSchedule(), activeSchedule); replaceSchedule(getActiveSchedule(), activeSchedule);
}} }}
renderItem={course => ( renderItem={({ id, course }) => (
<PopupCourseBlock key={course.id} course={course} colors={course.colors} /> <PopupCourseBlock key={id} course={course} colors={course.colors} />
)} )}
/> />
)} )}

View File

@@ -1,8 +1,10 @@
import { Sidebar } from '@phosphor-icons/react'; import { Sidebar } from '@phosphor-icons/react';
import type { CalendarTabMessages } from '@shared/messages/CalendarMessages'; import type { CalendarTabMessages } from '@shared/messages/CalendarMessages';
import { OptionsStore } from '@shared/storage/OptionsStore';
import type { Course } from '@shared/types/Course'; import type { Course } from '@shared/types/Course';
import { CRX_PAGES } from '@shared/types/CRXPages'; import { CRX_PAGES } from '@shared/types/CRXPages';
import { openReportWindow } from '@shared/util/openReportWindow'; import { openReportWindow } from '@shared/util/openReportWindow';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import CalendarBottomBar from '@views/components/calendar/CalendarBottomBar'; import CalendarBottomBar from '@views/components/calendar/CalendarBottomBar';
import CalendarGrid from '@views/components/calendar/CalendarGrid'; import CalendarGrid from '@views/components/calendar/CalendarGrid';
import CalendarHeader from '@views/components/calendar/CalendarHeader/CalendarHeader'; 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 { CalendarContext } from '@views/contexts/CalendarContext';
import useCourseFromUrl from '@views/hooks/useCourseFromUrl'; import useCourseFromUrl from '@views/hooks/useCourseFromUrl';
import { useFlattenedCourseSchedule } from '@views/hooks/useFlattenedCourseSchedule'; import { useFlattenedCourseSchedule } from '@views/hooks/useFlattenedCourseSchedule';
import useWhatsNewPopUp from '@views/hooks/useWhatsNew';
import { MessageListener } from 'chrome-extension-toolkit'; import { MessageListener } from 'chrome-extension-toolkit';
import clsx from 'clsx'; import clsx from 'clsx';
import type { ReactNode } from 'react';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import OutwardArrowIcon from '~icons/material-symbols/arrow-outward'; import OutwardArrowIcon from '~icons/material-symbols/arrow-outward';
@@ -23,17 +27,39 @@ import { Button } from '../common/Button';
import { LargeLogo } from '../common/LogoIcon'; import { LargeLogo } from '../common/LogoIcon';
import Text from '../common/Text/Text'; import Text from '../common/Text/Text';
import CalendarFooter from './CalendarFooter'; import CalendarFooter from './CalendarFooter';
import DiningAppPromo from './DiningAppPromo';
/** /**
* Calendar page component * Calendar page component
*/ */
export default function Calendar(): JSX.Element { export default function Calendar(): ReactNode {
const { courseCells, activeSchedule } = useFlattenedCourseSchedule(); 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 [course, setCourse] = useState<Course | null>(useCourseFromUrl());
const [showPopup, setShowPopup] = useState<boolean>(course !== null); const [showPopup, setShowPopup] = useState<boolean>(course !== null);
const [showSidebar, setShowSidebar] = useState<boolean>(true); const showWhatsNewDialog = useWhatsNewPopUp();
const [showUTDiningPromo, setShowUTDiningPromo] = useState<boolean>(false);
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(() => { useEffect(() => {
const listener = new MessageListener<CalendarTabMessages>({ const listener = new MessageListener<CalendarTabMessages>({
@@ -59,10 +85,19 @@ export default function Calendar(): JSX.Element {
if (course) setShowPopup(true); if (course) setShowPopup(true);
}, [course]); }, [course]);
useEffect(() => {
// Load the user's preference for the promo
OptionsStore.get('showUTDiningPromo').then(show => {
setShowUTDiningPromo(show);
});
}, []);
if (isSidebarStatePending) return null;
return ( return (
<CalendarContext.Provider value> <CalendarContext.Provider value>
<div className='h-full w-full flex flex-col'> <div className='h-full w-full flex flex-col'>
<div className='h-screen flex overflow-auto'> <div className='screenshot:calendar-target h-screen flex overflow-auto'>
<div <div
className={clsx( 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', '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',
@@ -97,21 +132,44 @@ export default function Calendar(): JSX.Element {
<CalendarSchedules /> <CalendarSchedules />
<Divider orientation='horizontal' size='100%' /> <Divider orientation='horizontal' size='100%' />
<ResourceLinks /> <ResourceLinks />
<Divider orientation='horizontal' size='100%' />
{/* <TeamLinks /> */} {/* <TeamLinks /> */}
<a <Divider orientation='horizontal' size='100%' />
href={CRX_PAGES.REPORT} {showUTDiningPromo && (
className='flex items-center gap-spacing-2 text-ut-burntorange underline-offset-2 hover:underline' <DiningAppPromo
target='_blank' onClose={() => {
rel='noreferrer' setShowUTDiningPromo(false);
onClick={event => { OptionsStore.set('showUTDiningPromo', false);
event.preventDefault(); }}
openReportWindow(); />
}} )}
> <div className='flex flex-col gap-spacing-3'>
<Text variant='p'>Send us Feedback!</Text> <a
<OutwardArrowIcon className='h-4 w-4' /> href={CRX_PAGES.REPORT}
</a> className='flex items-center gap-spacing-2 text-ut-burntorange underline-offset-2 hover:underline'
target='_blank'
rel='noreferrer'
onClick={event => {
event.preventDefault();
openReportWindow();
}}
>
<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> </div>
<CalendarFooter /> <CalendarFooter />
@@ -123,7 +181,7 @@ export default function Calendar(): JSX.Element {
// scrollbarGutter: 'stable', // 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 <CalendarHeader
sidebarOpen={showSidebar} sidebarOpen={showSidebar}
@@ -131,7 +189,11 @@ export default function Calendar(): JSX.Element {
setShowSidebar(!showSidebar); 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} /> <CalendarGrid courseCells={courseCells} setCourse={setCourse} />
</div> </div>
<CalendarBottomBar courseCells={courseCells} setCourse={setCourse} /> <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 { ColorPickerProvider } from '@views/contexts/ColorPickerContext';
import type { CalendarGridCourse } from '@views/hooks/useFlattenedCourseSchedule'; import type { CalendarGridCourse } from '@views/hooks/useFlattenedCourseSchedule';
import clsx from 'clsx'; import clsx from 'clsx';
import type { ReactNode } from 'react';
import React from 'react'; import React from 'react';
import CalendarCourseBlock from './CalendarCourseCell'; import CalendarCourseBlock from './CalendarCourseCell';
@@ -18,44 +19,38 @@ type CalendarBottomBarProps = {
* @param courses - The list of courses to display in the calendar. * @param courses - The list of courses to display in the calendar.
* @returns The rendered bottom bar component. * @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 asyncCourseCells = courseCells?.filter(block => block.async);
const displayCourses = asyncCourseCells && asyncCourseCells.length > 0; const displayCourses = asyncCourseCells && asyncCourseCells.length > 0;
if (!displayCourses) return null;
return ( return (
<div className='w-full flex pl-spacing-7 pr-spacing-3 pt-spacing-4'> <div className='w-full flex pl-spacing-7 pr-spacing-3 pt-spacing-4'>
<div <div className='flex flex-grow items-center gap-1 text-nowrap'>
className={clsx('flex flex-grow items-center gap-1 text-nowrap', { <Text variant='p' className='text-ut-black uppercase'>
'py-7.5': !displayCourses, Async / Other
})} </Text>
> <Text variant='h4' className='text-theme-offwhite/50'>
{displayCourses && (
<> </Text>
<Text variant='p' className='text-ut-black uppercase'> <div className='inline-flex gap-2.5'>
Unscheduled <ColorPickerProvider>
</Text> {asyncCourseCells.map(block => {
<Text variant='h4' className='text-theme-offwhite/50'> const { courseDeptAndInstr, status, className } = block.componentProps;
return (
</Text> <CalendarCourseBlock
<div className='inline-flex gap-2.5'> key={block.course.uniqueId}
<ColorPickerProvider> courseDeptAndInstr={courseDeptAndInstr}
{asyncCourseCells.map(block => { status={status}
const { courseDeptAndInstr, status, className } = block.componentProps; className={clsx(className, 'w-35! h-12.5! items-center')}
return ( onClick={() => setCourse(block.course)}
<CalendarCourseBlock blockData={block}
key={block.course.uniqueId} />
courseDeptAndInstr={courseDeptAndInstr} );
status={status} })}
className={clsx(className, 'w-35! h-12.5! items-center')} </ColorPickerProvider>
onClick={() => setCourse(block.course)} </div>
blockData={block}
/>
);
})}
</ColorPickerProvider>
</div>
</>
)}
</div> </div>
</div> </div>
); );

View File

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

View File

@@ -2,14 +2,18 @@ import type { Course } from '@shared/types/Course';
import CalendarCourseCell from '@views/components/calendar/CalendarCourseCell'; import CalendarCourseCell from '@views/components/calendar/CalendarCourseCell';
import Text from '@views/components/common/Text/Text'; import Text from '@views/components/common/Text/Text';
import { ColorPickerProvider } from '@views/contexts/ColorPickerContext'; import { ColorPickerProvider } from '@views/contexts/ColorPickerContext';
import { useSentryScope } from '@views/contexts/SentryContext';
import type { CalendarGridCourse } from '@views/hooks/useFlattenedCourseSchedule'; import type { CalendarGridCourse } from '@views/hooks/useFlattenedCourseSchedule';
import React from 'react'; import React, { Fragment } from 'react';
import CalendarCell from './CalendarGridCell'; import CalendarCell from './CalendarGridCell';
import { calculateCourseCellColumns } from './utils';
const daysOfWeek = ['MON', 'TUE', 'WED', 'THU', 'FRI']; const daysOfWeek = ['MON', 'TUE', 'WED', 'THU', 'FRI'];
const hoursOfDay = Array.from({ length: 14 }, (_, index) => index + 8); const hoursOfDay = Array.from({ length: 14 }, (_, index) => index + 8);
const IS_STORYBOOK = import.meta.env.STORYBOOK;
interface Props { interface Props {
courseCells?: CalendarGridCourse[]; courseCells?: CalendarGridCourse[];
saturdayClass?: boolean; saturdayClass?: boolean;
@@ -30,13 +34,13 @@ function makeGridRow(row: number, cols: number): JSX.Element {
const hour = hoursOfDay[row]!; const hour = hoursOfDay[row]!;
return ( return (
<> <Fragment key={row}>
<CalendarHour hour={hour} /> <CalendarHour hour={hour} />
<div className='grid-row-span-2 w-4 border-b border-r border-gray-300' /> <div className='grid-row-span-2 w-4 border-b border-r border-gray-300' />
{[...Array(cols).keys()].map(col => ( {[...Array(cols).keys()].map(col => (
<CalendarCell key={`${row}${col}`} row={row} col={col} /> <CalendarCell key={`${row}${col}`} row={row} col={col} />
))} ))}
</> </Fragment>
); );
} }
@@ -56,23 +60,40 @@ export default function CalendarGrid({
setCourse, setCourse,
}: React.PropsWithChildren<Props>): JSX.Element { }: React.PropsWithChildren<Props>): JSX.Element {
return ( 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 */} {/* Displaying day labels */}
<div />
<div className='w-4 border-b border-r border-gray-300' />
{daysOfWeek.map(day => ( {daysOfWeek.map(day => (
<div className='h-4 flex items-end justify-center border-b border-r border-gray-300 pb-1.5'> <div
<Text key={day} variant='small' className='text-center text-ut-burntorange' as='div'> // Full height with background to prevent grid lines from showing behind
{day} className='sticky top-[85px] z-10 row-span-2 h-7 flex flex-col items-end self-start justify-end bg-white'
</Text> 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> </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))} {[...Array(13).keys()].map(i => makeGridRow(i, 5))}
<CalendarHour hour={21} /> <CalendarHour hour={21} />
{Array(6) {Array(6)
.fill(1) .fill(1)
.map(() => ( .map((_, i) => (
<div className='h-4 flex items-end justify-center border-r border-gray-300' /> // 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> <ColorPickerProvider>
{courseCells && <AccountForCourseConflicts courseCells={courseCells} setCourse={setCourse} />} {courseCells && <AccountForCourseConflicts courseCells={courseCells} setCourse={setCourse} />}
@@ -89,6 +110,12 @@ interface AccountForCourseConflictsProps {
// TODO: Possibly refactor to be more concise // TODO: Possibly refactor to be more concise
// TODO: Deal with react strict mode (wacky movements) // TODO: Deal with react strict mode (wacky movements)
function AccountForCourseConflicts({ courseCells, setCourse }: AccountForCourseConflictsProps): JSX.Element[] { function AccountForCourseConflicts({ courseCells, setCourse }: AccountForCourseConflictsProps): JSX.Element[] {
// Sentry is not defined in storybook.
// This is a valid use case for a condition hook, since IS_STORYBOOK is determined at build time,
// it doesn't change between renders.
// eslint-disable-next-line react-hooks/rules-of-hooks
const [sentryScope] = IS_STORYBOOK ? [undefined] : useSentryScope();
// Groups by dayIndex to identify overlaps // Groups by dayIndex to identify overlaps
const days = courseCells.reduce( const days = courseCells.reduce(
(acc, cell: CalendarGridCourse) => { (acc, cell: CalendarGridCourse) => {
@@ -103,31 +130,15 @@ function AccountForCourseConflicts({ courseCells, setCourse }: AccountForCourseC
); );
// Check for overlaps within each day and adjust gridColumnIndex and totalColumns // Check for overlaps within each day and adjust gridColumnIndex and totalColumns
Object.values(days).forEach((dayCells: CalendarGridCourse[]) => { Object.values(days).forEach((dayCells: CalendarGridCourse[], idx) => {
// Sort by start time to ensure proper columnIndex assignment try {
dayCells.sort((a, b) => a.calendarGridPoint.startIndex - b.calendarGridPoint.startIndex); calculateCourseCellColumns(dayCells);
} catch (error) {
dayCells.forEach((cell, _, arr) => { console.error(`Error calculating course cell columns ${idx}`, error);
let columnIndex = 1; if (sentryScope) {
cell.totalColumns = 1; sentryScope.captureException(error);
// Check for overlaps and adjust columnIndex as needed
for (let otherCell of arr) {
if (otherCell !== cell) {
const isOverlapping =
otherCell.calendarGridPoint.startIndex < cell.calendarGridPoint.endIndex &&
otherCell.calendarGridPoint.endIndex > cell.calendarGridPoint.startIndex;
if (isOverlapping) {
// Adjust columnIndex to not overlap with the otherCell
if (otherCell.gridColumnStart && otherCell.gridColumnStart >= columnIndex) {
columnIndex = otherCell.gridColumnStart + 1;
}
cell.totalColumns += 1;
}
}
} }
cell.gridColumnStart = columnIndex; }
cell.gridColumnEnd = columnIndex + 1;
});
}); });
return courseCells return courseCells

View File

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

View File

@@ -1,16 +1,17 @@
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'; import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react';
import { CalendarDots, Export, FilePng, Sidebar } from '@phosphor-icons/react'; import { CalendarDots, Export, FileCode, FilePng, Sidebar } from '@phosphor-icons/react';
import styles from '@views/components/calendar/CalendarHeader/CalendarHeader.module.scss'; import styles from '@views/components/calendar/CalendarHeader/CalendarHeader.module.scss';
import { Button } from '@views/components/common/Button'; import { Button } from '@views/components/common/Button';
import DialogProvider from '@views/components/common/DialogProvider/DialogProvider'; import DialogProvider from '@views/components/common/DialogProvider/DialogProvider';
import Divider from '@views/components/common/Divider'; import Divider from '@views/components/common/Divider';
import { ExtensionRootWrapper, styleResetClass } from '@views/components/common/ExtensionRoot/ExtensionRoot'; import { ExtensionRootWrapper, styleResetClass } from '@views/components/common/ExtensionRoot/ExtensionRoot';
import { LargeLogo } from '@views/components/common/LogoIcon';
import ScheduleTotalHoursAndCourses from '@views/components/common/ScheduleTotalHoursAndCourses'; import ScheduleTotalHoursAndCourses from '@views/components/common/ScheduleTotalHoursAndCourses';
import useSchedules from '@views/hooks/useSchedules'; import useSchedules from '@views/hooks/useSchedules';
import clsx from 'clsx'; import clsx from 'clsx';
import React from 'react'; import React from 'react';
import { saveAsCal, saveCalAsPng } from '../utils'; import { handleExportJson, saveAsCal, saveCalAsPng } from '../utils';
interface CalendarHeaderProps { interface CalendarHeaderProps {
sidebarOpen?: boolean; sidebarOpen?: boolean;
@@ -27,7 +28,7 @@ export default function CalendarHeader({ sidebarOpen, onSidebarToggle }: Calenda
return ( return (
<div <div
style={{ scrollbarGutter: 'stable' }} 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 && ( {!sidebarOpen && (
<Button <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'> <div className='min-w-[11.5rem] screenshot:transform-origin-left screenshot:scale-120'>
<ScheduleTotalHoursAndCourses <ScheduleTotalHoursAndCourses
scheduleName={activeSchedule.name} scheduleName={activeSchedule.name}
@@ -62,7 +66,7 @@ export default function CalendarHeader({ sidebarOpen, onSidebarToggle }: Calenda
className={clsx([ className={clsx([
styleResetClass, styleResetClass,
'mt-spacing-3', '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-[closed]:(opacity-0 scale-95)',
'data-[enter]:(ease-out-expo duration-150)', 'data-[enter]:(ease-out-expo duration-150)',
'data-[leave]:(ease-out duration-50)', 'data-[leave]:(ease-out duration-50)',
@@ -94,6 +98,18 @@ export default function CalendarHeader({ sidebarOpen, onSidebarToggle }: Calenda
Save as .cal Save as .cal
</Button> </Button>
</MenuItem> </MenuItem>
<MenuItem>
<Button
className='w-full flex justify-start'
onClick={() => handleExportJson(activeSchedule.id)}
color='ut-black'
size='small'
variant='minimal'
icon={FileCode}
>
Save as .json
</Button>
</MenuItem>
{/* <MenuItem> {/* <MenuItem>
<Button color='ut-black' size='small' variant='minimal' icon={FileTxt}> <Button color='ut-black' size='small' variant='minimal' icon={FileTxt}>
Export Unique IDs Export Unique IDs

View File

@@ -0,0 +1,69 @@
import { AppStoreLogo, ForkKnife, X as CloseIcon } from '@phosphor-icons/react';
import { UT_DINING_APP_STORE_URL } from '@shared/util/appUrls';
import { Button } from '@views/components/common/Button';
import Text from '@views/components/common/Text/Text';
import React from 'react';
interface DiningAppPromoProps {
onClose: () => void;
}
/**
* Promotional component for the UT Dining app
*/
export default function DiningAppPromo({ onClose }: DiningAppPromoProps) {
return (
<div className='relative min-w-[16.25rem] w-full flex items-center gap-spacing-3 border border-ut-offwhite/50 rounded p-spacing-4'>
<div className='flex items-center justify-center'>
<ForkKnife className='h-6 w-6 text-ut-black' />
</div>
<div className='flex flex-col gap-spacing-1'>
<Text as='p' variant='small' className='whitespace-normal text-ut-black'>
Download our new{' '}
<a
href={UT_DINING_APP_STORE_URL}
target='_blank'
rel='noreferrer'
aria-label='UT Dining app'
className='text-ut-burntorange underline'
>
UT Dining app
</a>{' '}
to explore all dining options on campus!
</Text>
<div className='mt-spacing-2 flex items-center gap-spacing-2'>
<Text variant='mini' className='text-ut-black'>
Available on
</Text>
<a
href={UT_DINING_APP_STORE_URL}
target='_blank'
rel='noreferrer'
aria-label='Download on App Store'
className='text-theme-black transition-colors hover:text-ut-burntorange'
>
<AppStoreLogo className='h-4.5 w-4.5' />
</a>
{/* <a
href={UT_DINING_GOOGLE_PLAY_URL}
target='_blank'
rel='noreferrer'
aria-label='Download on Google Play'
className='text-theme-black hover:text-ut-burntorange transition-colors'
>
<GooglePlayLogo className='h-4.5 w-4.5' />
</a> */}
</div>
</div>
<Button
variant='minimal'
color='theme-black'
onClick={onClose}
className='absolute right-1 top-1 h-5 w-5 p-0'
icon={CloseIcon}
aria-label='Close dining app promo'
title='Close'
/>
</div>
);
}

View File

@@ -13,10 +13,10 @@ interface LinkItem {
} }
const links: LinkItem[] = [ const links: LinkItem[] = [
{ // {
text: "Spring '25 Course Schedule", // text: "Spring '25 Course Schedule",
url: 'https://utdirect.utexas.edu/apps/registrar/course_schedule/20252/', // url: 'https://utdirect.utexas.edu/apps/registrar/course_schedule/20252/',
}, // },
{ {
text: 'Course Schedule Archives', text: 'Course Schedule Archives',
url: 'https://registrar.utexas.edu/schedules/archive', url: 'https://registrar.utexas.edu/schedules/archive',

View File

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

View File

@@ -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,40 @@
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 { CalendarGridCourse } from '@views/hooks/useFlattenedCourseSchedule';
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, beforeEach, describe, expect, it, vi } from 'vitest';
import { formatToHHMMSS } from './utils'; import type { CalendarCourseCellProps } from './CalendarCourseCell';
import {
allDatesInRanges,
calculateCourseCellColumns,
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', () => { describe('formatToHHMMSS', () => {
it('should format minutes to HHMMSS format', () => { it('should format minutes to HHMMSS format', () => {
@@ -24,3 +58,624 @@ describe('formatToHHMMSS', () => {
expect(result).toBe(expected); 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();
});
});
describe('calculateCourseCellColumns', () => {
let testIdCounter = 0;
const makeCell = (startIndex: number, endIndex: number): CalendarGridCourse => {
if (endIndex <= startIndex && !(startIndex === -1 && endIndex === -1)) {
throw new Error('Test writer error: startIndex must be strictly less than endIndex');
}
const cell = {
calendarGridPoint: {
dayIndex: 1,
startIndex,
endIndex,
},
componentProps: {} as unknown as CalendarCourseCellProps,
course: {} as unknown as Course,
async: false,
} satisfies CalendarGridCourse;
/* eslint no-underscore-dangle: ["error", { "allow": ["__test_id"] }] */
(cell as unknown as { __test_id: number }).__test_id = testIdCounter++;
return cell;
};
/**
* Creates test cases for calculateCourseCellColumns
* @param cellConfigs - Array of [startIndex, endIndex, totalColumns, gridColumnStart, gridColumnEnd]
* @returns Tuple of [cells, expectedCells]
*/
const makeCellsTest = (
cellConfigs: Array<[number, number, number, number, number]>
): [CalendarGridCourse[], CalendarGridCourse[]] => {
// Create cells with only start/end indices
const cells = cellConfigs.map(([startIndex, endIndex]) => makeCell(startIndex, endIndex));
// Create expected cells with all properties set
const expectedCells = structuredClone<CalendarGridCourse[]>(cells);
cellConfigs.forEach((config, index) => {
const [, , totalColumns, gridColumnStart, gridColumnEnd] = config;
expectedCells[index]!.totalColumns = totalColumns;
expectedCells[index]!.gridColumnStart = gridColumnStart;
expectedCells[index]!.gridColumnEnd = gridColumnEnd;
});
return [cells, expectedCells];
};
beforeEach(() => {
// Ensure independence between tests
testIdCounter = 0;
});
it('should do nothing to an empty array if no courses are present', () => {
const cells: CalendarGridCourse[] = [];
calculateCourseCellColumns(cells);
expect(cells).toEqual([]);
});
it('should set the right values for one course cell', () => {
const [cells, expectedCells] = makeCellsTest([[13, 15, 1, 1, 2]]);
calculateCourseCellColumns(cells);
expect(cells).toEqual(expectedCells);
});
it('should handle two separated courses', () => {
// These two cells can share a column, because they aren't concurrent
const [cells, expectedCells] = makeCellsTest([
[13, 15, 1, 1, 2],
[16, 18, 1, 1, 2],
]);
calculateCourseCellColumns(cells);
expect(cells).toEqual(expectedCells);
});
it('should handle two back-to-back courses', () => {
// These two cells can share a column, because they aren't concurrent
const [cells, expectedCells] = makeCellsTest([
[13, 15, 1, 1, 2],
[15, 17, 1, 1, 2],
]);
calculateCourseCellColumns(cells);
expect(cells).toEqual(expectedCells);
});
it('should handle two concurrent courses', () => {
// These two cells must be in separate columns, because they are concurrent
const [cells, expectedCells] = makeCellsTest([
[13, 15, 2, 1, 2],
[14, 16, 2, 2, 3],
]);
calculateCourseCellColumns(cells);
expect(cells).toEqual(expectedCells);
});
it('should handle a simple grid', () => {
// Two columns
const [cells, expectedCells] = makeCellsTest([
[13, 15, 2, 1, 2], // start in left-most column
[15, 17, 2, 1, 2], // compact into left column
[13, 17, 2, 2, 3], // take up second column
]);
calculateCourseCellColumns(cells);
expect(cells).toEqual(expectedCells);
});
it('should handle a simple grid, flipped', () => {
// Ensures `totalColumns` is calculated correctly
const [cells, expectedCells] = makeCellsTest([
[13, 17, 2, 1, 2],
[15, 17, 2, 2, 3],
[13, 15, 2, 2, 3],
]);
calculateCourseCellColumns(cells);
expect(cells).toEqual(expectedCells);
});
it('should handle a weird grid', () => {
// Three columns
const [cells, expectedCells] = makeCellsTest([
[13, 15, 3, 1, 2],
[14, 18, 3, 2, 3],
[14, 16, 3, 3, 4],
[15, 17, 3, 1, 2], // compacted into left-most columns
]);
calculateCourseCellColumns(cells);
expect(cells).toEqual(expectedCells);
});
it('should handle many clean concurrent courses', () => {
// All cells here are concurrent, 8 columns
const [cells, expectedCells] = makeCellsTest([
[10, 16, 8, 1, 2],
[12, 16, 8, 2, 3],
[13, 16, 8, 3, 4],
[13, 16, 8, 4, 5],
[13, 19, 8, 5, 6],
[13, 19, 8, 6, 7],
[14, 18, 8, 7, 8],
[15, 19, 8, 8, 9],
]);
calculateCourseCellColumns(cells);
expect(cells).toEqual(expectedCells);
});
it('should handle many clean concurrent courses with one partially-concurrent', () => {
// Despite adding another course, we don't need to increase
// the number of columns, because we can compact
const [cells, expectedCells] = makeCellsTest([
[10, 16, 8, 1, 2],
[11, 15, 8, 2, 3], // new course, only overlaps with some
[12, 16, 8, 3, 4],
[13, 16, 8, 4, 5],
[13, 16, 8, 5, 6],
[13, 19, 8, 6, 7],
[13, 19, 8, 7, 8],
[14, 18, 8, 8, 9],
[15, 19, 8, 2, 3], // compacts to be under new course
]);
calculateCourseCellColumns(cells);
expect(cells).toEqual(expectedCells);
});
it("shouldn't crash on courses without times", () => {
const cells = [makeCell(-1, -1), makeCell(-1, -1)];
cells[1]!.async = true; // see if we can ignore async and non-async courses without times
const expectedCells = structuredClone<CalendarGridCourse[]>(cells);
calculateCourseCellColumns(cells);
expect(cells).toEqual(expectedCells);
});
});

View File

@@ -1,9 +1,38 @@
import { tz, TZDate } from '@date-fns/tz';
import exportSchedule from '@pages/background/lib/exportSchedule';
import { UserScheduleStore } from '@shared/storage/UserScheduleStore'; import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
import type { Course } from '@shared/types/Course';
import type { CourseMeeting } from '@shared/types/CourseMeeting';
import Instructor from '@shared/types/Instructor';
import type { UserSchedule } from '@shared/types/UserSchedule'; import type { UserSchedule } from '@shared/types/UserSchedule';
import { downloadBlob } from '@shared/util/downloadBlob'; import { downloadBlob } from '@shared/util/downloadBlob';
import { englishStringifyList } from '@shared/util/string';
import type { CalendarGridCourse } from '@views/hooks/useFlattenedCourseSchedule';
import type { Serialized } from 'chrome-extension-toolkit'; 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 { 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 = { export const CAL_MAP = {
Sunday: 'SU', Sunday: 'SU',
Monday: 'MO', Monday: 'MO',
@@ -14,6 +43,17 @@ export const CAL_MAP = {
Saturday: 'SA', Saturday: 'SA',
} as const satisfies Record<string, string>; } 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. * Retrieves the schedule from the UserScheduleStore based on the active index.
* @returns A promise that resolves to the retrieved schedule. * @returns A promise that resolves to the retrieved schedule.
@@ -38,85 +78,244 @@ export const formatToHHMMSS = (minutes: number) => {
return `${hours}${mins}00`; 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). * 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. * Fetches the current active schedule and converts it into an ICS string.
* Downloads the ICS file to the user's device. * Downloads the ICS file to the user's device.
*/ */
export const saveAsCal = async () => { export const saveAsCal = async () => {
const schedule = await getSchedule(); // Assumes this fetches the current active schedule const schedule = await getSchedule();
let icsString = 'BEGIN:VCALENDAR\nVERSION:2.0\nCALSCALE:GREGORIAN\nX-WR-CALNAME:My Schedule\n';
if (!schedule) { if (!schedule) {
throw new Error('No schedule found'); throw new Error('No schedule found');
} }
schedule.courses.forEach(course => { const icsString = scheduleToIcsString(schedule);
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';
downloadBlob(icsString, 'CALENDAR', 'schedule.ics'); downloadBlob(icsString, 'CALENDAR', 'schedule.ics');
}; };
/**
* Saves current schedule to JSON that can be imported on other devices.
* @param id - Provided schedule ID to download
*/
export const handleExportJson = async (id: string) => {
const jsonString = await exportSchedule(id);
if (jsonString) {
const schedules = await UserScheduleStore.get('schedules');
const schedule = schedules.find(s => s.id === id);
const fileName = `${schedule?.name ?? `schedule_${id}`}_${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
await downloadBlob(jsonString, 'JSON', fileName);
} else {
console.error('Error exporting schedule: jsonString is undefined');
}
};
/** /**
* Saves the calendar as a PNG image. * Saves the calendar as a PNG image.
* *
* @param calendarRef - The reference to the calendar component. * @param calendarRef - The reference to the calendar component.
*/ */
export const saveCalAsPng = () => { export const saveCalAsPng = () => {
const WIDTH_PX = 1165;
const HEIGHT_PX = 754;
const SCALE = 2;
const rootNode = document.createElement('div'); const rootNode = document.createElement('div');
rootNode.style.backgroundColor = 'white'; rootNode.style.backgroundColor = 'white';
rootNode.style.position = 'fixed'; rootNode.style.position = 'fixed';
rootNode.style.zIndex = '1000'; rootNode.style.zIndex = '1000';
rootNode.style.top = '-10000px'; rootNode.style.top = '-10000px';
rootNode.style.left = '-10000px'; rootNode.style.left = '-10000px';
rootNode.style.width = '1165px'; rootNode.style.width = `${WIDTH_PX}px`;
rootNode.style.height = '754px'; rootNode.style.height = `${HEIGHT_PX}px`;
document.body.appendChild(rootNode); document.body.appendChild(rootNode);
const clonedNode = document.querySelector('#root')!.cloneNode(true) as HTMLDivElement; const clonedNode = document.querySelector('#root')!.cloneNode(true) as HTMLDivElement;
clonedNode.style.backgroundColor = 'white'; clonedNode.style.backgroundColor = 'white';
(clonedNode.firstChild as HTMLDivElement).classList.add('screenshot-in-progress'); (clonedNode.firstChild as HTMLDivElement).classList.add('screenshot-in-progress');
return new Promise<void>((resolve, reject) => { const calendarTarget = clonedNode.querySelector('.screenshot\\:calendar-target') as HTMLDivElement;
requestAnimationFrame(async () => { calendarTarget.style.width = `${WIDTH_PX}px`;
rootNode.appendChild(clonedNode); calendarTarget.style.height = `${HEIGHT_PX}px`;
return new Promise<void>((resolve, reject) => {
rootNode.appendChild(clonedNode);
requestAnimationFrame(async () => {
try { try {
const screenshotBlob = await toBlob(clonedNode, { const screenshotBlob = await toBlob(clonedNode, {
cacheBust: true, cacheBust: true,
canvasWidth: 1165 * 2, canvasWidth: WIDTH_PX * SCALE,
canvasHeight: 754 * 2, canvasHeight: HEIGHT_PX * SCALE,
skipAutoScale: true, skipAutoScale: true,
pixelRatio: 2, pixelRatio: SCALE,
}); });
if (!screenshotBlob) { if (!screenshotBlob) {
@@ -134,3 +333,136 @@ export const saveCalAsPng = () => {
}); });
}); });
}; };
/**
* Determines all the connected components in the list of cells, where two cells
* are "connected" if there is a path (potentially through other cells) where
* each neighboring cells have overlapping start/end times
*
* @param cells - An array of cells to go on the calendar grid
* @returns An array of connected components, where the inner array is a list of
* all cells that there's a path to (potentially through other intervals)
* without crossing a time gap
*
* @remarks The internal fields cell.concurrentCells and cell.hasParent are
* modified by this function
*
* @example [[8am, 9am), [8:30am, 10am), [9:30am, 11am)] // is all one connected component
* @example [[8am, 9am), [8:30am, 10am), [10am, 11am)] // has two connected components, [[8am, 9am), [8:30am, 10am)] and [[10am, 11am)]]
*/
const findConnectedComponents = (cells: CalendarGridCourse[]): CalendarGridCourse[][] => {
const connectedComponents: CalendarGridCourse[][] = [];
for (let i = 0; i < cells.length; i++) {
const cell = cells[i]!;
if (!cell.concurrentCells || cell.concurrentCells.length === 0) {
// If this cell isn't already part of an existing connected component,
// then we need to make a new one.
connectedComponents.push([]);
}
connectedComponents.at(-1)!.push(cell);
for (let j = i + 1; j < cells.length; j++) {
const otherCell = cells[j]!;
if (otherCell.calendarGridPoint.startIndex >= cell.calendarGridPoint.endIndex) {
break;
}
// By ordering of cells array, we know cell.startTime <= other.startTime
// By the if check above, we know cell.endTime > other.endTime
// So, they're concurrent
// Also, by initializing j to i + 1, we know we don't have duplicates
cell.concurrentCells!.push(otherCell);
otherCell.concurrentCells!.push(cell);
}
}
return connectedComponents;
};
/**
* Assigns column positions to each cell in a set of calendar grid cells.
* Ensures that overlapping cells are placed in different columns.
*
* Inspired by the Greedy Interval-Partitioning algorithm.
*
* @param cells - An array of calendar grid course cells to position, must be
* sorted in increasing order of start time
* @throws Error if there's no available column for a cell (should never happen if totalColumns is calculated correctly)
* @remarks The number of columns created is strictly equal to the minimum needed by a perfectly optimal algorithm.
* The minimum number of columns needed is the maximum number of events that happen concurrently.
* Research Interval Graphs for more info https://en.wikipedia.org/wiki/Interval_graph
*/
const assignColumns = (cells: CalendarGridCourse[]) => {
const availableColumns = [true];
for (const cell of cells) {
availableColumns.fill(true);
for (const otherCell of cell.concurrentCells!) {
if (otherCell.gridColumnStart !== undefined) {
availableColumns[otherCell.gridColumnStart - 1] = false;
}
}
// Find an available column, or create one if all columns are full
let column = availableColumns.indexOf(true);
if (column === -1) {
column = availableColumns.length;
availableColumns.push(true);
}
// CSS Grid uses 1-based indexing
cell.gridColumnStart = column + 1;
cell.gridColumnEnd = column + 2;
}
for (const cell of cells) {
cell.totalColumns = availableColumns.length;
}
};
/**
* Calculates the column positions for course cells in a calendar grid.
* This function handles the layout algorithm for displaying overlapping course meetings
* in a calendar view. It identifies connected components of overlapping courses,
* determines the number of columns needed for each component, and assigns appropriate
* column positions to each cell.
*
* @param dayCells - An array of calendar grid course cells for a specific day
*/
export const calculateCourseCellColumns = (dayCells: CalendarGridCourse[]) => {
// Sort by start time, increasing
// This is necessary for the correctness of the column assignment
const cells = dayCells
.filter(
cell =>
!cell.async &&
cell.calendarGridPoint &&
typeof cell.calendarGridPoint.startIndex === 'number' &&
cell.calendarGridPoint.startIndex >= 0
)
.toSorted((a, b) => a.calendarGridPoint.startIndex - b.calendarGridPoint.startIndex);
// Initialize metadata
for (const cell of cells) {
cell.concurrentCells = [];
cell.gridColumnStart = undefined;
cell.gridColumnEnd = undefined;
}
// Construct connected components, set concurrent neighbors
const connectedComponents = findConnectedComponents(cells);
// Assign columns for each connectedComponents
for (const cc of connectedComponents) {
assignColumns(cc);
}
// Clean up
for (const cell of cells) {
delete cell.concurrentCells;
}
};

View File

@@ -6,7 +6,7 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
const changelog = new URL('/CHANGELOG.md', import.meta.url).href; const changelog = new URL('/CHANGELOG-local.md', import.meta.url).href;
/** /**
* Renders a popup component for displaying the changelog. * Renders a popup component for displaying the changelog.

View File

@@ -46,7 +46,7 @@ export function usePrompt(): (info: PromptInfo, options?: DialogOptions) => void
{info.description} {info.description}
</Text> </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 options
); );

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import type { Icon, IconProps } from '@phosphor-icons/react'; import type { Icon, IconProps } from '@phosphor-icons/react';
import type { MIMETypeValue } from '@shared/types/MIMEType';
import type { ThemeColor } from '@shared/types/ThemeColors'; import type { ThemeColor } from '@shared/types/ThemeColors';
import { getThemeColorHexByName, getThemeColorRgbByName } from '@shared/util/themeColors'; import { getThemeColorHexByName, getThemeColorRgbByName } from '@shared/util/themeColors';
import Text from '@views/components/common/Text/Text'; import Text from '@views/components/common/Text/Text';
@@ -16,6 +17,7 @@ interface Props {
disabled?: boolean; disabled?: boolean;
title?: string; title?: string;
color: ThemeColor; color: ThemeColor;
accept?: MIMETypeValue | MIMETypeValue[];
} }
/** /**
@@ -34,6 +36,7 @@ export default function FileUpload({
disabled, disabled,
title, title,
color, color,
accept,
children, children,
}: React.PropsWithChildren<Props>): JSX.Element { }: React.PropsWithChildren<Props>): JSX.Element {
const Icon = icon; const Icon = icon;
@@ -41,6 +44,9 @@ export default function FileUpload({
const colorHex = getThemeColorHexByName(color); const colorHex = getThemeColorHexByName(color);
const colorRgb = getThemeColorRgbByName(color)?.join(' '); const colorRgb = getThemeColorRgbByName(color)?.join(' ');
// Convert accept to a comma-separated string if it's an array
const acceptValue = Array.isArray(accept) ? accept.join(',') : accept;
return ( return (
<label <label
style={ style={
@@ -78,7 +84,13 @@ export default function FileUpload({
{children} {children}
</Text> </Text>
)} )}
<input type='file' className='hidden' disabled={disabled} onChange={disabled ? undefined : onChange} /> <input
type='file'
{...(accept ? { accept: acceptValue } : {})}
className='hidden'
disabled={disabled}
onChange={disabled ? undefined : onChange}
/>
</label> </label>
); );
} }

View File

@@ -102,7 +102,7 @@ export function useMigrationDialog() {
{ {
title: 'This extension has updated!', title: 'This extension has updated!',
description: 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} />, buttons: close => <MigrationButtons close={close} />,
}, },

View File

@@ -19,13 +19,17 @@ export default function ScheduleDropdown({ defaultOpen, children }: ScheduleDrop
const [activeSchedule] = useSchedules(); const [activeSchedule] = useSchedules();
return ( return (
<div className='border border-ut-offwhite/50 rounded bg-white'> <div className='max-h-[200px] flex flex-col border border-ut-offwhite/50 rounded bg-white'>
<Disclosure defaultOpen={defaultOpen}> <Disclosure defaultOpen={defaultOpen}>
{({ open }) => ( {({ open }) => (
<> <>
<DisclosureButton className='w-full flex items-center border-none bg-transparent px-3.5 py-2.5 text-left'> <DisclosureButton className='w-full flex items-center border-none bg-transparent px-3.5 py-2.5 text-left'>
<div className='flex-1'> <div className='min-w-0 flex-1 overflow-hidden'>
<Text as='div' variant='h3' className='w-100% text-ut-burntorange normal-case!'> <Text
as='div'
variant='h3'
className='w-full truncate whitespace-nowrap text-ut-burntorange normal-case!'
>
{activeSchedule ? activeSchedule.name : 'Schedule'} {activeSchedule ? activeSchedule.name : 'Schedule'}
</Text> </Text>
<div className='flex gap-2.5 text-theme-black leading-[75%]!'> <div className='flex gap-2.5 text-theme-black leading-[75%]!'>
@@ -50,17 +54,17 @@ export default function ScheduleDropdown({ defaultOpen, children }: ScheduleDrop
<Transition <Transition
as='div' as='div'
className='overflow-hidden' className='flex flex-1 flex-col overflow-y-hidden'
enter='transition-[max-height,opacity,padding] duration-300 ease-in-out-expo' enter='transition-[max-height,opacity,padding] duration-300 ease-in-out-expo'
enterFrom='max-h-0 opacity-0 p-0.5' enterFrom='max-h-0 opacity-0 p-0.5'
enterTo='max-h-[440px] opacity-100 p-0' enterTo='max-h-[200px] opacity-100 p-0'
leave='transition-[max-height,opacity,padding] duration-300 ease-in-out-expo' leave='transition-[max-height,opacity,padding] duration-300 ease-in-out-expo'
leaveFrom='max-h-[440px] opacity-100 p-0' leaveFrom='max-h-[200px] opacity-100 p-0'
leaveTo='max-h-0 opacity-0 p-0.5' leaveTo='max-h-0 opacity-0 p-0.5'
> >
<div className='px-3.5 pb-2.5 pt-2'> <DisclosurePanel className='mx-1.75 mb-2.5 mt-2 flex flex-1 flex-col overflow-y-auto'>
<DisclosurePanel>{children}</DisclosurePanel> <div className='mx-1.75'>{children}</div>
</div> </DisclosurePanel>
</Transition> </Transition>
</> </>
)} )}

View File

@@ -109,12 +109,14 @@ export default function ScheduleListItem({ schedule, onClick }: ScheduleListItem
const handleDelete = () => { const handleDelete = () => {
showDialog({ showDialog({
title: 'Are you sure?', title: 'Delete schedule?',
description: ( description: (
<> <>
<Text>Deleting</Text> <Text>Deleting </Text>
<Text className='text-ut-burntorange'> {schedule.name} </Text> <Text className='text-ut-burntorange'>{schedule.name}</Text>
<Text>is permanent and will remove all added courses from that schedule.</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 // eslint-disable-next-line react/no-unstable-nested-components
@@ -126,12 +128,13 @@ export default function ScheduleListItem({ schedule, onClick }: ScheduleListItem
<Button <Button
variant='filled' variant='filled'
color='theme-red' color='theme-red'
icon={Trash}
onClick={() => { onClick={() => {
close(); close();
deleteSchedule(schedule.id); deleteSchedule(schedule.id);
}} }}
> >
Delete Permanently Delete permanently
</Button> </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,139 @@
import type { IconProps } from '@phosphor-icons/react';
import { CloudX, Coffee, ForkKnife, MapTrifold, Storefront } from '@phosphor-icons/react';
import { ExtensionStore } from '@shared/storage/ExtensionStore';
import { UT_DINING_PROMO_IMAGE_URL } from '@shared/util/appUrls';
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 = 2;
// 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: 'dining-halls-info',
icon: ForkKnife,
title: 'Dining Halls Info',
description: 'See daily menus and nutritional deets for J2, JCL, and Kins',
},
{
id: 'coffee-shops',
icon: Coffee,
title: 'Coffee Shops',
description: 'Need a Coffee Fix? Check operating times for your favorite campus cafes.',
},
{
id: 'convenience-stores',
icon: Storefront,
title: 'Convenience Stores',
description: 'Find hours for quick snacks and essentials on campus.',
},
{
id: 'microwave-map',
icon: MapTrifold,
title: 'Microwave Map',
description: 'Need to heat up your lunch? Find microwaves across campus!',
},
] 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>
) : (
<img
className='h-full w-full border border-ut-offwhite/50 rounded object-cover'
src={UT_DINING_PROMO_IMAGE_URL}
alt='UT Dining Promo'
/>
)}
</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

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

View File

@@ -140,7 +140,7 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
tickWidth: 1, tickWidth: 1,
tickLength: 10, tickLength: 10,
tickColor: '#9CADB7', tickColor: '#9CADB7',
crosshair: { color: extendedColors.theme.offwhite }, crosshair: { color: `${extendedColors.theme.offwhite}50` },
lineColor: '#9CADB7', lineColor: '#9CADB7',
}, },
yAxis: { yAxis: {
@@ -215,7 +215,7 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
options={{ options={{
...chartOptions, ...chartOptions,
title: { title: {
text: `There is currently no grade distribution data for ${course.department} ${course.number}`, text: `There is currently no grade distribution data for ${course.department} ${course.getNumberWithoutTerm()}`,
}, },
tooltip: { enabled: false }, tooltip: { enabled: false },
}} }}
@@ -228,7 +228,7 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
<Text variant='small' className='text-ut-black'> <Text variant='small' className='text-ut-black'>
Grade Distribution for{' '} Grade Distribution for{' '}
<Text variant='small' className='font-extrabold!' as='strong'> <Text variant='small' className='font-extrabold!' as='strong'>
{course.department} {course.number} {course.department} {course.getNumberWithoutTerm()}
</Text> </Text>
</Text> </Text>
<select <select
@@ -260,14 +260,15 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
))} ))}
</select> </select>
<Link variant='small' href={UT_GRADE_DISTRIBUTION_URL} className='link'> <Link variant='small' href={UT_GRADE_DISTRIBUTION_URL} className='link'>
About the data Data Source
</Link> </Link>
</div> </div>
{distributions[semester] && !distributions[semester]!.instructorIncluded && ( {distributions[semester] && !distributions[semester]!.instructorIncluded && (
<div className='mt-3 flex flex-wrap content-center items-center self-stretch justify-center gap-3 text-center'> <div className='mt-3 flex flex-wrap content-center items-center self-stretch justify-center gap-3 text-center'>
<Text variant='small' className='text-theme-red'> <Text variant='small' className='text-theme-red'>
We couldn&apos;t find {semester !== 'Aggregate' && ` ${semester}`} grades for this We couldn&apos;t find {semester !== 'Aggregate' && ` ${semester}`} grades for this
instructor, so here are the grades for all {course.department} {course.number} sections. instructor, so here are the grades for all {course.department}{' '}
{course.getNumberWithoutTerm()} sections.
</Text> </Text>
</div> </div>
)} )}

View File

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

View File

@@ -0,0 +1,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 Link from '@views/components/common/Link';
import Text from '@views/components/common/Text/Text'; 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 { createPortal } from 'react-dom';
import styles from './RecruitmentBanner.module.scss'; import styles from './RecruitmentBanner.module.scss';
const DISCORD_URL = 'https://discord.gg/7pQDBGdmb7'; const DISCORD_URL = 'https://discord.gg/7pQDBGdmb7';
const GITHUB_URL = 'https://github.com/Longhorn-Developers/UT-Registration-Plus'; 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. * 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 { export default function RecruitmentBanner(): JSX.Element | null {
const [container, setContainer] = useState<HTMLDivElement | null>(null); const [container, setContainer] = useState<HTMLDivElement | null>(null);
const recruitmentType = useMemo<RecruitmentType>(getRecruitmentType, []);
useEffect(() => { useEffect(() => {
if (!canRecruitFrom()) { if (recruitmentType === 'NONE') {
return; return;
} }
const container = document.createElement('div'); const container = document.createElement('div');
container.setAttribute('id', 'ut-registration-plus-table-head'); container.setAttribute('id', 'ut-registration-plus-table-head');
const table = document.querySelector('table'); const table = document.querySelector('table');
table!.before(container); table!.before(container);
setContainer(container); setContainer(container);
}, []); }, [recruitmentType]);
if (!container) { if (!container || recruitmentType === 'NONE') {
return null; return null;
} }
return createPortal( return createPortal(
<div className={styles.container}> recruitmentType === 'DEVELOPER' ? <DeveloperRecruitmentBanner /> : <DesignerRecruitmentBanner />,
<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>,
container 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']; const params = ['fos_fl', 'fos_cn'];
let department = ''; let department = '';
params.forEach(p => { params.forEach(p => {
@@ -66,8 +91,18 @@ export const canRecruitFrom = (): boolean => {
department = param; department = param;
} }
}); });
if (!department) { 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

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

View File

@@ -0,0 +1,795 @@
/* eslint-disable no-nested-ternary */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import DaySelector from './DaySelector';
import DevToggles from './DevToggles';
import FullscreenButton from './FullscreenButton';
import { graphNodes } from './graphNodes';
import type { ProcessInPersonMeetings } from './Map';
import { Path } from './Path';
import { calcDirectPathStats, PathStats } from './PathStats';
import TimeWarningLabel from './TimeWarningLabel';
import type { DayCode, NodeId, NodeType } from './types';
import { DAY_MAPPING } from './types';
import { getMidpoint } from './utils';
import ZoomPanControls from './ZoomPanControls';
// Image: 783x753
const UTMapURL = new URL('/src/assets/UT-Map.svg', import.meta.url).href;
const minZoom = 0.5;
const maxZoom = 5;
const zoomStep = 1.2;
// Define zoom level thresholds for showing different levels of detail
const ZOOM_LEVELS = {
LOW: 0.8, // Show minimal buildings at this zoom level and below
MEDIUM: 1.5, // Show moderate amount of buildings
HIGH: 2.5, // Show all buildings with full details
} as const;
type SelectedBuildings = {
start: NodeId | null;
end: NodeId | null;
};
type CampusMapProps = {
processedCourses: ProcessInPersonMeetings[];
};
/**
* Component representing the campus map with interactive features.
*
* @param processedCourses - Array of processed courses.
* @returns The rendered CampusMap component.
*
* @remarks
* This component renders a map of the campus with interactive features such as:
* - Selecting buildings to create a path.
* - Displaying daily paths between sequential classes.
* - Highlighting paths with less than 15 minutes transition time.
* - Zooming and panning the map.
* - Toggling visibility of different map elements.
*
* The rendered output includes:
* - An image of the campus map.
* - An SVG overlay with paths and buildings.
* - Controls for selecting the day and displaying path information.
* - Dev controls for toggling element visibility.
* - Zoom and pan controls.
*/
export default function CampusMap({ processedCourses }: CampusMapProps): JSX.Element {
// Core state
const [selected, setSelected] = useState<SelectedBuildings>({
start: null,
end: null,
});
const [selectedDay, setSelectedDay] = useState<DayCode | null>(null);
const [hoveredPathIndex, setHoveredPathIndex] = useState<number | null>(null);
const [toggledPathIndex, setToggledPathIndex] = useState<number | null>(null);
// Dev toggle state
const [dynamicRendering, setDynamicRendering] = useState<boolean>(true);
const [showBuildings, setShowBuildings] = useState<boolean>(true);
const [showBuildingText, setShowBuildingText] = useState<boolean>(true);
const [showPrioritizedOnly, setShowPrioritizedOnly] = useState<boolean>(false);
const [showIntersections, setShowIntersections] = useState<boolean>(false);
const [showWalkways, setShowWalkways] = useState<boolean>(false);
// Zoom and pan state
const [zoomLevel, setZoomLevel] = useState<number>(1);
const [panPosition, setPanPosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState<boolean>(false);
const [dragStart, setDragStart] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
// Refs
const mapContainerRef = useRef<HTMLDivElement | null>(null);
// Function to calculate the current viewport in SVG coordinates
const calculateViewport = useCallback(() => {
if (!mapContainerRef.current) return null;
const container = mapContainerRef.current;
const rect = container.getBoundingClientRect();
// SVG dimensions from viewBox
const svgWidth = 783;
const svgHeight = 753;
// Calculate visible area in SVG coordinates
const scaleFactor = 1 / zoomLevel;
const visibleWidth = rect.width * scaleFactor;
const visibleHeight = rect.height * scaleFactor;
// Calculate the center point in SVG coordinates after pan
const centerX = svgWidth / 2 - panPosition.x * scaleFactor;
const centerY = svgHeight / 2 - panPosition.y * scaleFactor;
return {
left: centerX - visibleWidth / 2,
right: centerX + visibleWidth / 2,
top: centerY - visibleHeight / 2,
bottom: centerY + visibleHeight / 2,
width: visibleWidth,
height: visibleHeight,
};
}, [zoomLevel, panPosition]);
// Check if a node is in the viewport
const isNodeInViewport = useCallback(
(
node: { x: number; y: number },
viewport: {
left: number;
right: number;
top: number;
bottom: number;
} | null
) => {
if (!dynamicRendering) return true;
if (!viewport) return true;
return (
node.x >= viewport.left &&
node.x <= viewport.right &&
node.y >= viewport.top &&
node.y <= viewport.bottom
);
},
[dynamicRendering]
);
// Path calculations
const getDailyPaths = useCallback((courses: ProcessInPersonMeetings[]) => {
const sortedCourses = [...courses].sort((a, b) => a.normalizedStartTime - b.normalizedStartTime);
const paths = [];
for (let i = 0; i < sortedCourses.length - 1; i++) {
const currentCourse = sortedCourses[i];
const nextCourse = sortedCourses[i + 1];
if (currentCourse && nextCourse && currentCourse.location?.building && nextCourse.location?.building) {
paths.push({
start: currentCourse.location.building,
end: nextCourse.location.building,
startTime: currentCourse.normalizedEndTime,
endTime: nextCourse.normalizedStartTime,
colors: currentCourse.colors,
startCourseName: currentCourse.fullName,
endCourseName: nextCourse.fullName,
});
}
}
return paths;
}, []);
const relevantPaths = useMemo(() => {
if (!selectedDay) return [];
const coursesForDay = processedCourses.filter(course => course.day === DAY_MAPPING[selectedDay]);
const paths = getDailyPaths(coursesForDay);
return paths.map(path => ({
...path,
timeBetweenClasses: Math.floor(path.endTime - path.startTime),
}));
}, [selectedDay, processedCourses, getDailyPaths]);
// Memoized set of important buildings - buildings in active paths or daily routes
const importantBuildings = useMemo(() => {
const result = new Set<NodeId>();
// Add selected buildings
if (selected.start) result.add(selected.start);
if (selected.end) result.add(selected.end);
// Add buildings in the daily paths
relevantPaths?.forEach(path => {
result.add(path.start);
result.add(path.end);
});
return result;
}, [selected.start, selected.end, relevantPaths]);
// Memoized set of buildings to show based on zoom level and grid clustering
const visibleBuildings = useMemo(() => {
// Start with important buildings (selected or in active paths)
const result = new Set<NodeId>(importantBuildings);
const viewport = calculateViewport();
if (!dynamicRendering) {
Object.entries(graphNodes).forEach(([id, node]) => {
if (node.type === 'building' && isNodeInViewport(node, viewport)) {
result.add(id);
}
});
return result;
}
// If showing prioritized buildings only, return just the important ones
if (showPrioritizedOnly) {
return result;
}
// If we're zoomed in enough, show all buildings in viewport
if (zoomLevel >= ZOOM_LEVELS.HIGH) {
Object.entries(graphNodes).forEach(([id, node]) => {
if (node.type === 'building' && isNodeInViewport(node, viewport)) {
result.add(id);
}
});
return result;
}
// At medium zoom, show more buildings but still cluster them
if (zoomLevel >= ZOOM_LEVELS.MEDIUM) {
// Create a grid-based clustering with medium density
const gridSize = 40;
const grid: Record<string, NodeId[]> = {};
Object.entries(graphNodes).forEach(([id, node]) => {
if (node.type === 'building' && isNodeInViewport(node, viewport)) {
const gridX = Math.floor(node.x / gridSize);
const gridY = Math.floor(node.y / gridSize);
const gridId = `${gridX}-${gridY}`;
if (!grid[gridId]) {
grid[gridId] = [];
}
grid[gridId].push(id);
}
});
// Select one building per grid cell
Object.values(grid).forEach(buildings => {
if (buildings.length > 0) {
// Sort to ensure consistent selection
const sorted = [...buildings].sort();
if (sorted[0]) {
result.add(sorted[0]);
}
}
});
return result;
}
// At low zoom, create a sparser grid
const gridSize = 70;
const grid: Record<string, NodeId[]> = {};
Object.entries(graphNodes).forEach(([id, node]) => {
if (node.type === 'building' && isNodeInViewport(node, viewport)) {
const gridX = Math.floor(node.x / gridSize);
const gridY = Math.floor(node.y / gridSize);
const gridId = `${gridX}-${gridY}`;
if (!grid[gridId]) {
grid[gridId] = [];
}
grid[gridId].push(id);
}
});
// Select one building per grid cell
Object.values(grid).forEach(buildings => {
if (buildings.length > 0) {
// Sort to ensure consistent selection
const sorted = [...buildings].sort();
if (sorted[0]) {
result.add(sorted[0]);
}
// For grid cells with many buildings, maybe show a second one too
if (sorted.length > 3 && zoomLevel > ZOOM_LEVELS.LOW && sorted[1]) {
result.add(sorted[1]);
}
}
});
return result;
}, [importantBuildings, calculateViewport, dynamicRendering, showPrioritizedOnly, zoomLevel, isNodeInViewport]);
// Determine which intersections to show based on zoom level
const visibleIntersections = useMemo(() => {
const result = new Set<NodeId>();
const viewport = calculateViewport();
// Only process if intersections should be shown
if (!showIntersections) return result;
if (!dynamicRendering) {
Object.entries(graphNodes).forEach(([id, node]) => {
if (node.type === 'intersection' && isNodeInViewport(node, viewport)) {
result.add(id);
}
});
return result;
}
// Show all intersections at high zoom
if (zoomLevel >= ZOOM_LEVELS.HIGH) {
Object.entries(graphNodes).forEach(([id, node]) => {
if (node.type === 'intersection' && isNodeInViewport(node, viewport)) {
result.add(id);
}
});
return result;
}
// At medium zoom, show a subset
if (zoomLevel >= ZOOM_LEVELS.MEDIUM) {
Object.entries(graphNodes).forEach(([id, node]) => {
if (node.type === 'intersection' && isNodeInViewport(node, viewport)) {
// Show every 2nd intersection
const nodeIndex = parseInt(id.replace(/\D/g, '') || '0', 10);
if (nodeIndex % 2 === 0) {
result.add(id);
}
}
});
return result;
}
// At low zoom, show very few intersections
Object.entries(graphNodes).forEach(([id, node]) => {
if (node.type === 'intersection' && isNodeInViewport(node, viewport)) {
// Show only every 4th intersection
const nodeIndex = parseInt(id.replace(/\D/g, '') || '0', 10);
if (nodeIndex % 4 === 0) {
result.add(id);
}
}
});
return result;
}, [calculateViewport, dynamicRendering, showIntersections, zoomLevel, isNodeInViewport]);
// Determine which walkways to show based on zoom level
const visibleWalkways = useMemo(() => {
const result = new Set<NodeId>();
const viewport = calculateViewport();
// Only process if walkways should be shown
if (!showWalkways) return result;
if (!dynamicRendering) {
Object.entries(graphNodes).forEach(([id, node]) => {
if (node.type === 'walkway' && isNodeInViewport(node, viewport)) {
result.add(id);
}
});
return result;
}
// Show all walkways at high zoom
if (zoomLevel >= ZOOM_LEVELS.HIGH) {
Object.entries(graphNodes).forEach(([id, node]) => {
if (node.type === 'walkway' && isNodeInViewport(node, viewport)) {
result.add(id);
}
});
return result;
}
// At medium zoom, show a subset
if (zoomLevel >= ZOOM_LEVELS.MEDIUM) {
Object.entries(graphNodes).forEach(([id, node]) => {
if (node.type === 'walkway' && isNodeInViewport(node, viewport)) {
// Show every 3rd walkway
const nodeIndex = parseInt(id.replace(/\D/g, '') || '0', 10);
if (nodeIndex % 3 === 0) {
result.add(id);
}
}
});
return result;
}
// At low zoom, show very few walkways
Object.entries(graphNodes).forEach(([id, node]) => {
if (node.type === 'walkway' && isNodeInViewport(node, viewport)) {
// Show only every 5th walkway
const nodeIndex = parseInt(id.replace(/\D/g, '') || '0', 10);
if (nodeIndex % 5 === 0) {
result.add(id);
}
}
});
return result;
}, [calculateViewport, dynamicRendering, showWalkways, zoomLevel, isNodeInViewport]);
// Determine if a node should be shown based on type and zoom level
const shouldShowNode = useCallback(
(type: NodeType, id: NodeId): boolean => {
// Always show selected buildings
if (id === selected.start || id === selected.end) return true;
switch (type) {
case 'building':
return showBuildings && visibleBuildings.has(id);
case 'intersection':
return visibleIntersections.has(id);
case 'walkway':
return visibleWalkways.has(id);
default:
return false;
}
},
[showBuildings, selected, visibleBuildings, visibleIntersections, visibleWalkways]
);
// Get the appropriate node size based on zoom level with maximum cap
const getNodeSize = useCallback(
(type: NodeType): number => {
const baseSize = type === 'building' ? 6 : 4;
const minSize = baseSize * 0.8; // Minimum size at low zoom
const maxSize = baseSize * 0.5; // Maximum size cap
// If below minimum zoom level
if (zoomLevel <= ZOOM_LEVELS.LOW) {
return minSize;
}
// If above maximum zoom level, cap the size
if (zoomLevel >= ZOOM_LEVELS.HIGH) {
return maxSize;
}
// Scale size gradually between LOW and HIGH zoom levels
const zoomRatio = (zoomLevel - ZOOM_LEVELS.LOW) / (ZOOM_LEVELS.HIGH - ZOOM_LEVELS.LOW);
return minSize + zoomRatio * (maxSize - minSize);
},
[zoomLevel]
);
// Get the appropriate text size based on zoom level with maximum cap
const getTextSize = useCallback((): number => {
const minSize = 12; // Minimum text size at low zoom
const maxSize = 8; // Maximum text size cap
// If below minimum zoom level
if (zoomLevel <= ZOOM_LEVELS.LOW) {
return minSize;
}
// If above maximum zoom level, cap the size
if (zoomLevel >= ZOOM_LEVELS.HIGH) {
return maxSize;
}
// Scale text size gradually between LOW and HIGH zoom levels
const zoomRatio = (zoomLevel - ZOOM_LEVELS.LOW) / (ZOOM_LEVELS.HIGH - ZOOM_LEVELS.LOW);
return minSize + zoomRatio * (maxSize - minSize);
}, [zoomLevel]);
// Determine if text should be shown for a node
const shouldShowText = useCallback(
(type: NodeType, id: NodeId): boolean => {
// If building text is disabled in dev controls, don't show any text
if (!showBuildingText) return false;
if (type !== 'building') return false;
// Always show text for selected buildings
if (id === selected.start || id === selected.end) return true;
// Show text based on zoom level
return zoomLevel >= ZOOM_LEVELS.LOW;
},
[zoomLevel, selected, showBuildingText]
);
// Zoom and pan handlers
const handleZoomIn = useCallback(() => {
setZoomLevel(prev => Math.min(prev * zoomStep, maxZoom));
}, []);
const handleZoomOut = useCallback(() => {
setZoomLevel(prev => Math.max(prev / zoomStep, minZoom));
}, []);
const handleResetZoomPan = useCallback(() => {
setZoomLevel(1);
setPanPosition({ x: 0, y: 0 });
}, []);
const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
if (e.deltaY < 0) {
setZoomLevel(prev => Math.min(prev * zoomStep, maxZoom));
} else {
setZoomLevel(prev => Math.max(prev / zoomStep, minZoom));
}
}, []);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return; // Only handle left mouse button
setIsDragging(true);
setDragStart({ x: e.clientX, y: e.clientY });
}, []);
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (!isDragging) return;
const dx = e.clientX - dragStart.x;
const dy = e.clientY - dragStart.y;
setPanPosition(prev => ({ x: prev.x + dx, y: prev.y + dy }));
setDragStart({ x: e.clientX, y: e.clientY });
},
[isDragging, dragStart]
);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
}, []);
const handleMouseLeave = useCallback(() => {
setIsDragging(false);
}, []);
// Event handlers
const handleDaySelect = useCallback((day: DayCode) => {
setSelectedDay(prevDay => (prevDay === day ? null : day));
setHoveredPathIndex(null);
setToggledPathIndex(null);
}, []);
const handleBuildingSelect = useCallback((buildingId: NodeId) => {
setSelected(prev => {
if (!prev.start) return { ...prev, start: buildingId };
if (!prev.end) return { ...prev, end: buildingId };
return { start: buildingId, end: null };
});
}, []);
const handlePathClick = useCallback((index: number) => {
setToggledPathIndex(prevIndex => (prevIndex === index ? null : index));
}, []);
const shouldShowPath = useCallback(
(index: number) => {
if (hoveredPathIndex !== null) {
return hoveredPathIndex === index;
}
if (toggledPathIndex !== null) {
return toggledPathIndex === index;
}
return true;
},
[hoveredPathIndex, toggledPathIndex]
);
// Global mouse up handler
useEffect(() => {
const handleGlobalMouseUp = () => {
setIsDragging(false);
};
window.addEventListener('mouseup', handleGlobalMouseUp);
return () => {
window.removeEventListener('mouseup', handleGlobalMouseUp);
};
}, []);
return (
<div className='relative h-full w-full overflow-hidden' ref={mapContainerRef}>
{/* Map container with zoom and pan applied */}
<div
className={`relative h-full w-full ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`}
style={{
transform: `scale(${zoomLevel}) translate(${panPosition.x / zoomLevel}px, ${panPosition.y / zoomLevel}px)`,
transformOrigin: 'center center',
transition: isDragging ? 'none' : 'transform 0.1s ease-out',
}}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
>
{/* Map Image */}
<img src={UTMapURL} alt='UT Campus Map' className='h-full w-full object-contain' draggable={false} />
{/* SVG Overlay - ensuring it matches the image dimensions */}
<svg
className='absolute left-0 top-0 h-full w-full'
viewBox='0 0 783 753'
preserveAspectRatio='xMidYMid meet'
>
{/* Render buildings, intersections, and walkways */}
{Object.entries(graphNodes).map(
([id, node]) =>
shouldShowNode(node.type, id) && (
<g key={id}>
<circle
cx={node.x}
cy={node.y}
r={getNodeSize(node.type)}
fill={
id === selected.start
? '#579D42'
: id === selected.end
? '#D10000'
: node.type === 'building'
? '#BF5700'
: node.type === 'intersection'
? '#9CADB7'
: '#D6D2C400'
}
stroke={node.type !== 'walkway' ? 'white' : 'green'}
strokeWidth={zoomLevel < ZOOM_LEVELS.MEDIUM ? '1.5' : '2'}
className='cursor-pointer opacity-90'
onClick={() => handleBuildingSelect(id)}
/>
{node.type === 'building' && shouldShowText(node.type, id) && (
<text
x={node.x + 12}
y={node.y + 4}
fill='#000000'
fontSize={getTextSize()}
className='font-bold'
style={{
// Fade in text based on zoom level for smooth transition
opacity: zoomLevel < ZOOM_LEVELS.LOW ? zoomLevel / ZOOM_LEVELS.LOW : 1,
}}
>
{id}
</text>
)}
</g>
)
)}
{/* Render daily schedule paths */}
{relevantPaths.map(
(path, index) =>
shouldShowPath(index) && (
// eslint-disable-next-line react/no-array-index-key
<g key={`${path.start}-${path.end}-${index}`}>
<Path
startId={path.start}
endId={path.end}
graph={graphNodes}
color={path.colors?.primaryColor || '#BF5700'}
className='stroke-4 opacity-50 transition-opacity duration-300 hover:opacity-80'
/>
{path.timeBetweenClasses < 15 &&
(() => {
const midpoint = getMidpoint(path.start, path.end);
return midpoint ? (
<TimeWarningLabel
x={midpoint.x}
y={midpoint.y}
minutes={path.timeBetweenClasses}
/>
) : null;
})()}
</g>
)
)}
{/* Render user-selected path */}
{selected.start && selected.end && (
<Path
startId={selected.start}
endId={selected.end}
graph={graphNodes}
color='#BF5700'
className='opacity-75 transition-opacity duration-300 hover:opacity-100'
/>
)}
</svg>
</div>
{/* Fixed position controls that don't move with zoom/pan */}
<div className='absolute left-8 top-8 z-10 flex flex-col gap-4'>
{/* Day Selector */}
<DaySelector selectedDay={selectedDay} onDaySelect={handleDaySelect} />
{/* Zoom and Pan Controls */}
<ZoomPanControls
zoomIn={handleZoomIn}
zoomOut={handleZoomOut}
resetZoomPan={handleResetZoomPan}
zoomLevel={zoomLevel}
/>
{/* Fullscreen Button */}
<FullscreenButton containerRef={mapContainerRef} />
{/* Dev Toggles */}
<DevToggles
dynamicRendering={dynamicRendering}
showBuildings={showBuildings}
showIntersections={showIntersections}
showWalkways={showWalkways}
showBuildingText={showBuildingText}
showPrioritizedOnly={showPrioritizedOnly}
onToggleDynamicRendering={() => setDynamicRendering(prev => !prev)}
onToggleBuildings={() => setShowBuildings(prev => !prev)}
onToggleIntersections={() => setShowIntersections(prev => !prev)}
onToggleWalkways={() => setShowWalkways(prev => !prev)}
onToggleBuildingText={() => setShowBuildingText(prev => !prev)}
onTogglePrioritizedOnly={() => setShowPrioritizedOnly(prev => !prev)}
/>
</div>
{/* Path information */}
<div className='absolute right-8 top-8 z-10 max-h-[calc(100vh-120px)] flex flex-col gap-4 overflow-y-auto'>
{/* Path Statistics - show when a path is selected */}
{selected.start && selected.end && <PathStats startId={selected.start} endId={selected.end} />}
{/* Daily Paths Statistics - show when day is selected */}
{relevantPaths.length > 0 && (
<div className='rounded-md bg-white/90 p-3 shadow-sm'>
<div className='mb-2'>
<p className='text-sm font-medium'>Daily Transitions</p>
<p className='text-xs text-gray-600'>
Total time:{' '}
{relevantPaths.reduce(
(total, path) =>
total +
(calcDirectPathStats({ startId: path.start, endId: path.end })
?.walkingTimeMinutes || 0),
0
)}{' '}
min
</p>
<p className='text-xs text-gray-600'>
Total distance:{' '}
{relevantPaths.reduce(
(total, path) =>
total +
(calcDirectPathStats({ startId: path.start, endId: path.end })
?.distanceInFeet || 0),
0
)}{' '}
ft
</p>
</div>
<div className='space-y-4'>
{relevantPaths.map((path, index) => (
<div
// eslint-disable-next-line react/no-array-index-key
key={`path-info-${index}`}
className={`cursor-pointer space-y-1 text-xs transition-colors duration-200 ${
toggledPathIndex === index ? 'bg-gray-100' : ''
}`}
style={{
borderLeft: `3px solid ${path.colors?.primaryColor || '#BF5700'}`,
}}
onMouseEnter={() => setHoveredPathIndex(index)}
onMouseLeave={() => setHoveredPathIndex(null)}
onClick={() => handlePathClick(index)}
>
<p className='ml-2'>{path.startCourseName}</p>
<p className='ml-2'>
(
{
calcDirectPathStats({ startId: path.start, endId: path.end })
?.walkingTimeMinutes
}{' '}
min,{' '}
{calcDirectPathStats({ startId: path.start, endId: path.end })?.distanceInFeet}{' '}
ft)
{' - '}
{path.timeBetweenClasses} min transition
{path.timeBetweenClasses < 15 && ' ⚠️'}
</p>
<p className='ml-2'>{path.endCourseName}</p>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { Button } from '../common/Button';
import type { DayCode } from './types';
import { DAY_MAPPING } from './types';
interface DaySelectorProps {
selectedDay: DayCode | null;
onDaySelect: (day: DayCode) => void;
}
/**
* DaySelector component allows users to select a day from a list of days.
*
* @param selectedDay - The currently selected day.
* @param onDaySelect - Callback function to handle day selection.
*
* @returns The rendered DaySelector component.
*/
// const DaySelector = ({ selectedDay, onDaySelect }: DaySelectorProps): JSX.Element => (
export default function DaySelector({ selectedDay, onDaySelect }: DaySelectorProps): JSX.Element {
return (
<div className='flex gap-2 rounded-md bg-white/90 p-2 shadow-sm'>
{(Object.keys(DAY_MAPPING) as DayCode[]).map(day => (
<Button
key={day}
onClick={() => onDaySelect(day)}
color='ut-burntorange'
variant={selectedDay === day ? 'filled' : 'minimal'}
size='mini'
className='px-3 py-1'
>
{day}
</Button>
))}
</div>
);
}

View File

@@ -0,0 +1,101 @@
import React, { useState } from 'react';
interface DevTogglesProps {
dynamicRendering: boolean;
showBuildings: boolean;
showIntersections: boolean;
showWalkways: boolean;
showBuildingText: boolean;
showPrioritizedOnly: boolean;
onToggleDynamicRendering: () => void;
onToggleBuildings: () => void;
onToggleIntersections: () => void;
onToggleWalkways: () => void;
onToggleBuildingText: () => void;
onTogglePrioritizedOnly: () => void;
}
/**
* DevToggles component allows developers to toggle visibility of map elements.
*
* @param dynamicRendering - Whether to enable dynamic rendering.
* @param showBuildings - Whether to show buildings on the map.
* @param showIntersections - Whether to show intersections on the map.
* @param showWalkways - Whether to show walkways on the map.
* @param onToggleDynamicRendering - Callback function to toggle dynamic rendering.
* @param onToggleBuildings - Callback function to toggle buildings visibility.
* @param onToggleIntersections - Callback function to toggle intersections visibility.
* @param onToggleWalkways - Callback function to toggle walkways visibility.
*
* @returns The rendered DevToggles component.
*/
export default function DevToggles({
dynamicRendering,
showBuildings,
showIntersections,
showWalkways,
showBuildingText,
showPrioritizedOnly,
onToggleDynamicRendering,
onToggleBuildings,
onToggleIntersections,
onToggleWalkways,
onToggleBuildingText,
onTogglePrioritizedOnly,
}: DevTogglesProps): JSX.Element {
const [isCollapsed, setIsCollapsed] = useState<boolean>(false);
return (
<div className='flex flex-col gap-2 rounded-md bg-white/90 p-2 shadow-sm'>
<div className='flex items-center justify-between text-xs text-gray-700 font-semibold'>
<span>Dev Controls</span>
<button
onClick={() => setIsCollapsed(prev => !prev)}
className='ml-2 p-1 text-gray-500 hover:text-gray-800'
>
<svg
xmlns='http://www.w3.org/2000/svg'
width='14'
height='14'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
{isCollapsed ? <polyline points='6 9 12 15 18 9' /> : <polyline points='18 15 12 9 6 15' />}
</svg>
</button>
</div>
{!isCollapsed && (
<div className='flex flex-col gap-1'>
<label className='flex cursor-pointer items-center gap-2 text-xs'>
<input type='checkbox' checked={dynamicRendering} onChange={onToggleDynamicRendering} />
Dynamic Rendering
</label>
<label className='flex cursor-pointer items-center gap-2 text-xs'>
<input type='checkbox' checked={showBuildings} onChange={onToggleBuildings} />
Show Buildings
</label>
<label className='flex cursor-pointer items-center gap-2 text-xs'>
<input type='checkbox' checked={showBuildingText} onChange={onToggleBuildingText} />
Show Building Text
</label>
<label className='flex cursor-pointer items-center gap-2 text-xs'>
<input type='checkbox' checked={showPrioritizedOnly} onChange={onTogglePrioritizedOnly} />
Prioritized Buildings Only
</label>
<label className='flex cursor-pointer items-center gap-2 text-xs'>
<input type='checkbox' checked={showIntersections} onChange={onToggleIntersections} />
Show Intersections
</label>
<label className='flex cursor-pointer items-center gap-2 text-xs'>
<input type='checkbox' checked={showWalkways} onChange={onToggleWalkways} />
Show Walkways
</label>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,72 @@
import React, { useEffect, useState } from 'react';
import { Button } from '../common/Button';
interface FullscreenButtonProps {
containerRef: React.RefObject<HTMLDivElement>;
}
/**
* FullscreenButton component provides a toggle for fullscreen mode
*
* @param containerRef - Reference to the container element to make fullscreen.
*
* @returns The rendered FullscreenButton component.
*/
export default function FullscreenButton({ containerRef }: FullscreenButtonProps): JSX.Element {
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange);
};
}, []);
const toggleFullscreen = () => {
if (!document.fullscreenElement && containerRef.current) {
containerRef.current.requestFullscreen().catch(err => {
console.error(`Error attempting to enable fullscreen: ${err.message}`);
});
} else if (document.fullscreenElement) {
document.exitFullscreen().catch(err => {
console.error(`Error attempting to exit fullscreen: ${err.message}`);
});
}
};
return (
<div className='rounded-md bg-white/90 p-2 shadow-sm'>
<Button
onClick={toggleFullscreen}
color='ut-burntorange'
variant='minimal'
size='mini'
className='flex items-center gap-1 px-3 py-1'
>
<svg
xmlns='http://www.w3.org/2000/svg'
width='16'
height='16'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
{isFullscreen ? (
<path d='M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3' />
) : (
<path d='M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3' />
)}
</svg>
{isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}
</Button>
</div>
);
}

View File

@@ -0,0 +1,287 @@
import type { Course, StatusType } from '@shared/types/Course';
import type { CourseMeeting } from '@shared/types/CourseMeeting';
import { Button } from '@views/components/common/Button';
import Divider from '@views/components/common/Divider';
import { LargeLogo } from '@views/components/common/LogoIcon';
import Text from '@views/components/common/Text/Text';
import useChangelog from '@views/hooks/useChangelog';
import useSchedules from '@views/hooks/useSchedules';
import React, { useCallback, useEffect } from 'react';
import IconoirGitFork from '~icons/iconoir/git-fork';
import CalendarFooter from '../calendar/CalendarFooter';
import { CalendarSchedules } from '../calendar/CalendarSchedules';
import ImportantLinks from '../calendar/ImportantLinks';
import TeamLinks from '../calendar/TeamLinks';
import CampusMap from './CampusMap';
import { type DAY, DAYS } from './types';
const manifest = chrome.runtime.getManifest();
const LDIconURL = new URL('/src/assets/LD-icon.png', import.meta.url).href;
const dayToNumber = {
Monday: 0,
Tuesday: 1,
Wednesday: 2,
Thursday: 3,
Friday: 4,
Saturday: 5,
Sunday: 6,
} as const satisfies Record<string, number>;
/**
* Represents the details of an in-person meeting process.
*
* day - The day of the meeting.
* dayIndex - The index of the day in the week.
* fullName - The full name of the person.
* uid - The unique identifier of the person.
* time - The time of the meeting.
* normalizedStartTime - The normalized start time of the meeting.
* normalizedEndTime - The normalized end time of the meeting.
* startIndex - The start index of the meeting.
* endIndex - The end index of the meeting.
* location - The location of the meeting.
* status - The status of the meeting.
* colors - The colors associated with the course.
* course - The course details.
*/
export type ProcessInPersonMeetings = {
day: DAY;
dayIndex: number;
fullName: string;
uid: number;
time: string;
normalizedStartTime: number;
normalizedEndTime: number;
startIndex: number;
endIndex: number;
location: CourseMeeting['location'];
status: StatusType;
colors: Course['colors'];
course: Course;
};
/**
* Converts minutes to an index value.
* @param minutes - The number of minutes.
* @returns The index value.
*/
const convertMinutesToIndex = (minutes: number): number => Math.floor((minutes - 420) / 30);
/**
* Renders the map component for the UTRP (UT Registration Plus) extension.
*/
export default function Map(): JSX.Element {
const handleChangelogOnClick = useChangelog();
const [activeSchedule] = useSchedules();
/**
* Function to extract and format basic course information
*/
function extractCourseInfo(course: Course) {
const {
status,
schedule: { meetings },
} = course;
let courseDeptAndInstr = `${course.department} ${course.number}`;
const mainInstructor = course.instructors[0];
if (mainInstructor) {
courseDeptAndInstr += ` ${mainInstructor.toString({ format: 'first_last' })}`;
}
return { status, courseDeptAndInstr, meetings, course };
}
// /**
// * Function to process each in-person class into its distinct meeting objects for calendar grid
// */
// function processAsyncCourses({
// courseDeptAndInstr,
// status,
// course,
// }: {
// courseDeptAndInstr: string;
// status: StatusType;
// course: Course;
// }): CalendarGridCourse[] {
// return [
// {
// calendarGridPoint: {
// dayIndex: -1,
// startIndex: -1,
// endIndex: -1,
// },
// componentProps: {
// courseDeptAndInstr,
// status,
// colors: course.colors,
// },
// course,
// async: true,
// },
// ];
// }
/**
* Function to process each in-person class into its distinct meeting objects for calendar grid
*/
function processInPersonMeetings(
meeting: CourseMeeting,
courseDeptAndInstr: string,
status: StatusType,
course: Course
) {
const { days, location, startTime, endTime } = meeting;
const time = meeting.getTimeString({ separator: '-' });
const timeAndLocation = `${time}${location ? ` - ${location.building} ${location.room}` : ''}`;
const midnightIndex = 1440;
const normalizingTimeFactor = 720;
const normalizedStartTime = startTime >= midnightIndex ? startTime - normalizingTimeFactor : startTime;
const normalizedEndTime = endTime >= midnightIndex ? endTime - normalizingTimeFactor : endTime;
return days.map(day => ({
day,
dayIndex: dayToNumber[day],
// fullName: `${courseDeptAndInstr} - ${timeAndLocation}`,
fullName: `${timeAndLocation} - ${courseDeptAndInstr}`,
uid: course.uniqueId,
time,
normalizedStartTime,
normalizedEndTime,
startIndex: convertMinutesToIndex(normalizedStartTime),
endIndex: convertMinutesToIndex(normalizedEndTime),
location,
status,
colors: course.colors,
course,
}));
}
const processedCourses: ProcessInPersonMeetings[] = activeSchedule.courses.flatMap(course => {
const { status, courseDeptAndInstr, meetings } = extractCourseInfo(course);
// if (meetings.length === 0) {
// return processAsyncCourses({ courseDeptAndInstr, status, course });
// }
return meetings.flatMap(meeting =>
// if (meeting.days.includes(DAY_MAP.S) || meeting.startTime < 480) {
// return processAsyncCourses({ courseDeptAndInstr, status, course });
// }
processInPersonMeetings(meeting, courseDeptAndInstr, status, course)
);
});
const generateWeekSchedule = useCallback((): Record<DAY, string[]> => {
const weekSchedule: Record<string, string[]> = {};
processedCourses.forEach(course => {
const { day } = course;
// Add the course to the day's schedule
if (!weekSchedule[day]) weekSchedule[day] = [];
weekSchedule[day].push(course.fullName);
});
// TODO: Not the best way to do this
// currently weekSchedule is an object with keys as days and values as an array of courses
// we want to display the days in order, so we create a new object with the days in order
const orderedWeekSchedule: Record<DAY, string[]> = {
Monday: [],
Tuesday: [],
Wednesday: [],
Thursday: [],
Friday: [],
Saturday: [],
Sunday: [],
};
DAYS.forEach(day => {
if (weekSchedule[day]) {
orderedWeekSchedule[day] = weekSchedule[day];
}
});
// Sort each day based on the start time of the course
Object.entries(orderedWeekSchedule).forEach(([day, courses]) => {
orderedWeekSchedule[day as DAY] = courses.sort((courseA, courseB) => {
const courseAStartTime = processedCourses.find(
course => course.fullName === courseA
)?.normalizedStartTime;
const courseBStartTime = processedCourses.find(
course => course.fullName === courseB
)?.normalizedStartTime;
return (courseAStartTime ?? 0) - (courseBStartTime ?? 0);
});
});
return orderedWeekSchedule;
}, [processedCourses]);
useEffect(() => {
console.log('Active Schedule: ', activeSchedule);
console.log('processedCourses:', processedCourses);
console.log('generateWeekSchedule():', generateWeekSchedule());
}, [activeSchedule, processedCourses, generateWeekSchedule]);
return (
<div>
<header className='flex items-center gap-5 overflow-x-auto overflow-y-hidden border-b border-ut-offwhite px-7 py-4 md:overflow-x-hidden'>
<LargeLogo />
<Divider className='mx-2 self-center md:mx-4' size='2.5rem' orientation='vertical' />
<Text variant='h1' className='flex-1 text-ut-burntorange'>
UTRP Map
</Text>
<div className='hidden flex-row items-center justify-end gap-6 screenshot:hidden lg:flex'>
<Button variant='minimal' color='theme-black' onClick={handleChangelogOnClick}>
<IconoirGitFork className='h-6 w-6 text-ut-gray' />
<Text variant='small' className='text-ut-gray font-normal'>
v{manifest.version} - {process.env.NODE_ENV}
</Text>
</Button>
<img src={LDIconURL} alt='LD Icon' className='h-10 w-10 rounded-lg' />
</div>
</header>
<div className='h-full flex flex-row'>
<div className='h-full flex flex-none flex-col justify-between pb-5 screenshot:hidden'>
<div className='mb-3 h-full w-fit flex flex-col overflow-auto pb-2 pl-4.5 pr-4 pt-5'>
<CalendarSchedules />
<Divider orientation='horizontal' size='100%' className='my-5' />
<ImportantLinks />
<Divider orientation='horizontal' size='100%' className='my-5' />
<TeamLinks />
</div>
<CalendarFooter />
</div>
<div className='flex p-12'>
<CampusMap processedCourses={processedCourses} />
</div>
{/* Show week schedule */}
<div className='flex flex-col py-12'>
<p className='text-lg font-medium'>Week Schedule:</p>
{Object.entries(generateWeekSchedule()).map(([day, courses]) => (
<div key={day} className='flex flex-col pb-4'>
<p className='text-sm font-medium'>{day}</p>
<ul>
{courses.map(course => (
<li key={course} className='text-xs'>
{course}
</li>
))}
</ul>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { PathFinder } from './PathFinder';
import { type Graph, isValidNode, type NodeId } from './types';
type PathProps = {
startId: NodeId;
endId: NodeId;
graph: Graph;
color: string;
className?: string;
};
/**
* Renders a path between two nodes in a graph.
*
* @param startId - The ID of the starting node.
* @param endId - The ID of the ending node.
* @param graph - The graph object containing nodes and edges.
* @param color - The color of the path.
* @param className - Additional CSS classes for the path.
*
* @returns The rendered path as a series of SVG lines, or null if an error occurs.
*/
export const Path = ({ startId, endId, graph, color, className = '' }: PathProps): JSX.Element | null => {
try {
const pathFinder = new PathFinder(graph);
const path = pathFinder.findPath(startId, endId);
return (
<>
{path.slice(0, -1).map((nodeId, index) => {
const nextNodeId = path[index + 1];
if (!nextNodeId) return null;
const start = graph[nodeId];
const end = graph[nextNodeId];
if (!isValidNode(start) || !isValidNode(end)) return null;
return (
<line
key={`${nodeId}-${nextNodeId}`}
x1={start.x}
y1={start.y}
x2={end.x}
y2={end.y}
strokeLinecap='round'
stroke={color}
// TODO: use clsx
className={`stroke-8 ${className} opacity-50`}
/>
);
})}
</>
);
} catch (error) {
console.error('Error rendering path:', error instanceof Error ? error.message : 'Unknown error');
return null;
}
};

View File

@@ -0,0 +1,193 @@
/* eslint-disable max-classes-per-file */
import {
DIRECT_PATH_THRESHOLD,
type DistanceMap,
type Graph,
isValidNode,
type MapNode,
NEIGHBOR_DISTANCE_THRESHOLD,
type NodeId,
type PreviousMap,
} from './types';
import { calculateDistance, getNeighbors } from './utils';
/**
* Custom error class for handling pathfinding errors.
*/
export class PathFindingError extends Error {
constructor(message: string) {
super(message);
this.name = 'PathFindingError';
}
}
/**
* The `PathFinder` class is responsible for finding the shortest path between nodes in a graph.
* It uses Dijkstra's algorithm to compute the shortest path and supports bidirectional connections.
*/
export class PathFinder {
private graph: Graph;
private nodeConnections: Map<NodeId, Set<NodeId>>;
constructor(graph: Graph) {
this.graph = graph;
this.nodeConnections = this.buildNodeConnections();
}
private buildNodeConnections(): Map<NodeId, Set<NodeId>> {
const connections = new Map<NodeId, Set<NodeId>>();
// Initialize connections for each node
Object.keys(this.graph).forEach(nodeId => {
connections.set(nodeId, new Set<NodeId>());
});
// Build bidirectional connections
Object.keys(this.graph).forEach(nodeId => {
const neighbors = getNeighbors(nodeId, this.graph);
neighbors.forEach(neighbor => {
connections.get(nodeId)?.add(neighbor);
connections.get(neighbor)?.add(nodeId);
});
});
return connections;
}
public findPath(startId: NodeId, endId: NodeId): NodeId[] {
const startNode = this.graph[startId];
const endNode = this.graph[endId];
// Validate input
if (!isValidNode(startNode)) {
throw new PathFindingError(`Invalid start node: ${startId}`);
}
if (!isValidNode(endNode)) {
throw new PathFindingError(`Invalid end node: ${endId}`);
}
// Check for direct path possibility
if (this.shouldUseDirectPath(startNode, endNode)) {
return [startId, endId];
}
// Initialize Dijkstra's algorithm data structures
const distances = Object.keys(this.graph).reduce<DistanceMap>((acc, nodeId) => {
acc[nodeId] = nodeId === startId ? 0 : Infinity;
return acc;
}, {});
const previous = Object.keys(this.graph).reduce<PreviousMap>((acc, nodeId) => {
acc[nodeId] = null;
return acc;
}, {});
const unvisited = new Set(Object.keys(this.graph));
// Main Dijkstra's algorithm loop
while (unvisited.size > 0) {
const current = this.getMinDistanceNode(distances, unvisited);
if (current === null) break;
if (current === endId) break;
unvisited.delete(current);
// Use pre-computed connections
const neighbors = this.nodeConnections.get(current) ?? new Set<NodeId>();
neighbors.forEach(neighbor => {
if (!unvisited.has(neighbor)) return;
const currentNode = this.graph[current];
const neighborNode = this.graph[neighbor];
if (!isValidNode(currentNode) || !isValidNode(neighborNode)) return;
const distance = calculateDistance(currentNode, neighborNode);
const totalDistance = distances[current]! + distance;
if (totalDistance < distances[neighbor]!) {
distances[neighbor] = totalDistance;
previous[neighbor] = current;
}
});
}
// Reconstruct and validate path
const path = this.reconstructPath(previous, startId, endId);
// Verify path distance is reasonable
const totalPathDistance = this.calculatePathDistance(path);
if (totalPathDistance > NEIGHBOR_DISTANCE_THRESHOLD * path.length) {
console.warn(`Long path detected (${totalPathDistance.toFixed(2)} units) from ${startId} to ${endId}`);
}
return path;
}
private calculatePathDistance(path: NodeId[]): number {
let totalDistance = 0;
for (let i = 0; i < path.length - 1; i++) {
const currentNode = this.graph[path[i]!];
const nextNode = this.graph[path[i + 1]!];
if (isValidNode(currentNode) && isValidNode(nextNode)) {
totalDistance += calculateDistance(currentNode, nextNode);
}
}
return totalDistance;
}
private shouldUseDirectPath(start: MapNode, end: MapNode): boolean {
const distance = calculateDistance(start, end);
return distance <= DIRECT_PATH_THRESHOLD;
}
private getMinDistanceNode(distances: DistanceMap, unvisited: Set<NodeId>): NodeId | null {
let minDistance = Infinity;
let minNode: NodeId | null = null;
unvisited.forEach(nodeId => {
const distance = distances[nodeId] ?? Infinity;
if (distance < minDistance) {
minDistance = distance;
minNode = nodeId;
}
});
return minNode;
}
private reconstructPath(previous: PreviousMap, startId: NodeId, endId: NodeId): NodeId[] {
const path: NodeId[] = [];
let currentNode: NodeId | null = endId;
// Keep track of visited nodes to prevent infinite loops
const visited = new Set<NodeId>();
while (currentNode !== null) {
// Prevent infinite loops
if (visited.has(currentNode)) {
throw new PathFindingError('Circular path detected during reconstruction');
}
visited.add(currentNode);
path.unshift(currentNode);
const prevNode: NodeId | null = previous[currentNode] ?? null;
// If we can't find the previous node and we haven't reached the start,
// then the path is broken
if (prevNode === undefined) {
throw new PathFindingError('Path reconstruction failed: broken path chain');
}
currentNode = prevNode;
}
// Verify that we actually found a path to the start
if (path[0] !== startId) {
throw new PathFindingError('No valid path found between the specified nodes');
}
return path;
}
}

View File

@@ -0,0 +1,86 @@
import React from 'react';
import { graphNodes } from './graphNodes';
import { isValidNode, PIXELS_TO_FEET, WALKING_SPEED } from './types';
type PathStatsProps = {
startId: string;
endId: string;
};
/**
* Calculates the direct path statistics between two nodes.
*
* @param startId - The ID of the starting node.
* @param endId - The ID of the ending node.
*
* @returns The distance in feet and walking time in minutes between the two nodes.
*/
export const calcDirectPathStats = ({ startId, endId }: PathStatsProps) => {
const startNode = graphNodes[startId];
const endNode = graphNodes[endId];
if (!isValidNode(startNode) || !isValidNode(endNode)) {
return null;
}
// Calculate distance in pixels
const distanceInPixels = Math.sqrt((endNode.x - startNode.x) ** 2 + (endNode.y - startNode.y) ** 2);
// Convert to feet and calculate time
const distanceInFeet = Math.round(distanceInPixels * PIXELS_TO_FEET);
const walkingTimeMinutes = Math.ceil(distanceInFeet / WALKING_SPEED);
return {
distanceInFeet,
walkingTimeMinutes,
};
};
/**
* Component to display statistics about a path between two nodes on a map.
*
* @param startId - The ID of the starting node.
* @param endId - The ID of the ending node.
*
* @returns A JSX element displaying the path statistics, or null if the nodes are invalid.
*/
export const PathStats = ({ startId, endId }: PathStatsProps): JSX.Element | null => {
const startNode = graphNodes[startId];
const endNode = graphNodes[endId];
if (!isValidNode(startNode) || !isValidNode(endNode)) {
return null;
}
// Calculate distance in pixels
const distanceInPixels = Math.sqrt((endNode.x - startNode.x) ** 2 + (endNode.y - startNode.y) ** 2);
// Convert to feet and calculate time
const distanceInFeet = Math.round(distanceInPixels * PIXELS_TO_FEET);
const walkingTimeMinutes = Math.ceil(distanceInFeet / WALKING_SPEED);
return (
<div className='rounded-md bg-white/90 p-3 shadow-sm space-y-2'>
<h3 className='text-sm font-medium'>Path Statistics</h3>
<div className='space-y-1'>
<div className='flex justify-between text-xs'>
<span>Distance:</span>
<span className='font-medium'>{distanceInFeet} ft</span>
</div>
<div className='flex justify-between text-xs'>
<span>Walking Time:</span>
<span className='font-medium'>{walkingTimeMinutes} min</span>
</div>
<div className='flex justify-between text-xs'>
<span>From:</span>
<span className='font-medium'>{startId}</span>
</div>
<div className='flex justify-between text-xs'>
<span>To:</span>
<span className='font-medium'>{endId}</span>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,36 @@
import React from 'react';
interface TimeWarningLabelProps {
x: number;
y: number;
minutes: number;
}
/**
* TimeWarningLabel component that renders a warning label on a map.
* The label consists of a circle with a text inside it, indicating the number of minutes.
*
* @param x - The x-coordinate for the center of the circle.
* @param y - The y-coordinate for the center of the circle.
* @param minutes - The number of minutes to display inside the circle.
*
* @returns A JSX element representing the warning label.
*/
export default function TimeWarningLabel({ x, y, minutes }: TimeWarningLabelProps): JSX.Element {
return (
<g>
<circle cx={x} cy={y} r={12} fill='white' stroke='#FF4444' strokeWidth={2} />
<text
x={x}
y={y}
textAnchor='middle'
dominantBaseline='middle'
fill='#FF4444'
fontSize='10'
fontWeight='bold'
>
{minutes}&apos;
</text>
</g>
);
}

View File

@@ -0,0 +1,84 @@
import React from 'react';
import { Button } from '../common/Button';
interface ZoomPanControlsProps {
zoomIn: () => void;
zoomOut: () => void;
resetZoomPan: () => void;
zoomLevel: number;
}
/**
* ZoomPanControls component provides buttons for zooming and panning.
*
* @param zoomIn - Function to zoom in the map.
* @param zoomOut - Function to zoom out the map.
* @param resetZoomPan - Function to reset zoom and pan to default.
* @param zoomLevel - Current zoom level.
*
* @returns The rendered ZoomPanControls component.
*/
export default function ZoomPanControls({
zoomIn,
zoomOut,
resetZoomPan,
zoomLevel,
}: ZoomPanControlsProps): JSX.Element {
return (
<div className='flex gap-2 rounded-md bg-white/90 p-2 shadow-sm'>
<Button onClick={zoomIn} color='ut-burntorange' variant='minimal' size='mini' className='px-3 py-1'>
<svg
xmlns='http://www.w3.org/2000/svg'
width='16'
height='16'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<circle cx='11' cy='11' r='8' />
<line x1='21' y1='21' x2='16.65' y2='16.65' />
<line x1='11' y1='8' x2='11' y2='14' />
<line x1='8' y1='11' x2='14' y2='11' />
</svg>
</Button>
<Button onClick={zoomOut} color='ut-burntorange' variant='minimal' size='mini' className='px-3 py-1'>
<svg
xmlns='http://www.w3.org/2000/svg'
width='16'
height='16'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<circle cx='11' cy='11' r='8' />
<line x1='21' y1='21' x2='16.65' y2='16.65' />
<line x1='8' y1='11' x2='14' y2='11' />
</svg>
</Button>
<Button onClick={resetZoomPan} color='ut-burntorange' variant='minimal' size='mini' className='px-3 py-1'>
<svg
xmlns='http://www.w3.org/2000/svg'
width='16'
height='16'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<path d='M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z' />
<polyline points='9 22 9 12 15 12 15 22' />
</svg>
</Button>
<span className='flex items-center text-xs font-medium'>{Math.round(zoomLevel * 100)}%</span>
</div>
);
}

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More