Compare commits
61 Commits
v2.1.1
...
feature/ci
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e13722820b | ||
|
|
e0067f8a76 | ||
|
|
a66fd151dd | ||
|
|
006d375605 | ||
|
|
4bbddacabb | ||
|
|
18530d9d45 | ||
|
|
3d3e8ced6f | ||
|
|
c9df1bf344 | ||
|
|
d8216aefb6 | ||
|
|
aeac1bab5b | ||
|
|
1a0757c3e6 | ||
|
|
e8a8b8e1ae | ||
|
|
c21cbd77f0 | ||
| 99a035e29d | |||
|
|
64baa6d290 | ||
|
|
46fe591fa7 | ||
|
|
8f7e1bc0af | ||
|
|
9fc1098ef7 | ||
|
|
ae094416fc | ||
|
|
2e7dac1e3e | ||
|
|
7bea23a655 | ||
|
|
3d28869e92 | ||
|
|
f0f1f0b365 | ||
| be861b823c | |||
|
|
95de8df372 | ||
| 5994ded8be | |||
|
|
7b401add15 | ||
|
|
2d92dd47f0 | ||
|
|
eb8141ee8c | ||
|
|
2a50f5580d | ||
|
|
65bfb1d129 | ||
|
|
234f3d627d | ||
|
|
be1dccfcb9 | ||
|
|
454e5e807a | ||
|
|
29d20d5c5a | ||
|
|
e29546c727 | ||
|
|
5a89be6238 | ||
|
|
cfb5faa09b | ||
| 37471efb74 | |||
|
|
7a4f40a765 | ||
|
|
d11d55db66 | ||
|
|
76b6aa7c15 | ||
|
|
70d4fecad6 | ||
|
|
c3fa91752c | ||
|
|
7c2beef193 | ||
|
|
630d0d80d2 | ||
|
|
695743104c | ||
|
|
d014244b28 | ||
|
|
5cd56259f7 | ||
|
|
fa9f78b46e | ||
|
|
4a5f67f0fd | ||
|
|
3bed9cc27f | ||
|
|
0dcae25b93 | ||
|
|
ca734dcd39 | ||
|
|
9448072112 | ||
|
|
b1e98ca9d7 | ||
|
|
f036d409e6 | ||
|
|
5493c63f18 | ||
|
|
6c3139bf0f | ||
|
|
28ebb69612 | ||
|
|
008cb40cb8 |
8
.changeset/README.md
Normal file
8
.changeset/README.md
Normal 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)
|
||||
5
.changeset/chatty-needles-shave.md
Normal file
5
.changeset/chatty-needles-shave.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'ut-registration-plus': minor
|
||||
---
|
||||
|
||||
Add CI/CD
|
||||
11
.changeset/config.json
Normal file
11
.changeset/config.json
Normal 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": []
|
||||
}
|
||||
122
.dockerignore
122
.dockerignore
@@ -1,18 +1,23 @@
|
||||
# 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
|
||||
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,macos,node,react,storybookjs
|
||||
|
||||
### macOS ###
|
||||
### macOS
|
||||
|
||||
# General
|
||||
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
.\_\*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
@@ -22,221 +27,278 @@
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
### macOS Patch ###
|
||||
# iCloud generated files
|
||||
*.icloud
|
||||
### macOS Patch
|
||||
|
||||
# iCloud generated files
|
||||
|
||||
\*.icloud
|
||||
|
||||
### Node
|
||||
|
||||
### Node ###
|
||||
# Logs
|
||||
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
_.log
|
||||
npm-debug.log_
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# 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
|
||||
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
_.pid
|
||||
_.seed
|
||||
\*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
|
||||
coverage
|
||||
*.lcov
|
||||
\*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
|
||||
build
|
||||
|
||||
# Dependency directories
|
||||
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
\*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
\*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
.env.*
|
||||
.env.\*
|
||||
!.env.example
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
|
||||
.cache/
|
||||
|
||||
# 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
|
||||
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
|
||||
.temp
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
|
||||
.vscode-te
|
||||
node_modulesst
|
||||
|
||||
# yarn v2
|
||||
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
.pnp.\*
|
||||
|
||||
### Node Patch
|
||||
|
||||
### Node Patch ###
|
||||
# Serverless Webpack directories
|
||||
|
||||
.webpack/
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
# SvelteKit build / generate output
|
||||
|
||||
.svelte-kit
|
||||
|
||||
### react ###
|
||||
.DS_*
|
||||
**/*.backup.*
|
||||
**/*.back.*
|
||||
### react
|
||||
|
||||
*.sublime*
|
||||
.DS\__
|
||||
\*\*/_.backup._
|
||||
\*\*/_.back.\*
|
||||
|
||||
_.sublime_
|
||||
|
||||
psd
|
||||
thumb
|
||||
sketch
|
||||
|
||||
### VisualStudioCode
|
||||
|
||||
### VisualStudioCode ###
|
||||
.vscode/*
|
||||
.vscode/_
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/*.code-snippets
|
||||
!.vscode/_.code-snippets
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
|
||||
.history/
|
||||
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
|
||||
### VisualStudioCode Patch ###
|
||||
\*.vsix
|
||||
|
||||
### VisualStudioCode Patch
|
||||
|
||||
# Ignore all local history of files
|
||||
|
||||
.history
|
||||
.ionide
|
||||
|
||||
# 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)
|
||||
|
||||
package-lock.json
|
||||
storybook-static/
|
||||
package/
|
||||
|
||||
# Version control
|
||||
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Dependencies
|
||||
|
||||
.pnpm-store
|
||||
!pnpm-lock.yaml
|
||||
|
||||
# Testing
|
||||
|
||||
coverage
|
||||
.nyc_output
|
||||
|
||||
# OS files
|
||||
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Docker
|
||||
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose*
|
||||
docker-compose\*
|
||||
|
||||
# Documentation
|
||||
|
||||
README.md
|
||||
CHANGELOG.md
|
||||
CHANGELOG-local.md
|
||||
DOCKER_DEV_SETUP.md
|
||||
docs/
|
||||
|
||||
@@ -7,3 +7,6 @@ insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
|
||||
[*.nix]
|
||||
indent_size = 2
|
||||
|
||||
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
SENTRY_ORG=longhorn-developers
|
||||
SENTRY_PROJECT=ut-registration-plus
|
||||
SENTRY_AUTH_TOKEN=
|
||||
5
.github/ISSUE_TEMPLATE/bug_report.md
vendored
5
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,9 +1,10 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: '[BUG] '
|
||||
labels: 'bug'
|
||||
title: "[BUG] "
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Pre-submission Checklist**
|
||||
|
||||
5
.github/ISSUE_TEMPLATE/feature_request.md
vendored
5
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,9 +1,10 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest an idea for this project
|
||||
title: '[FEATURE] '
|
||||
labels: 'feature'
|
||||
title: "[FEATURE] "
|
||||
labels: feature
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Pre-submission Checklist**
|
||||
|
||||
12
.github/ISSUE_TEMPLATE/updating-build-dependencies.md
vendored
Normal file
12
.github/ISSUE_TEMPLATE/updating-build-dependencies.md
vendored
Normal 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
21
.github/dependabot.yml
vendored
Normal 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'
|
||||
90
.github/workflows/release.yml
vendored
90
.github/workflows/release.yml
vendored
@@ -1,25 +1,77 @@
|
||||
name: Create Release
|
||||
name: Release
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- production
|
||||
- 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 .
|
||||
- main
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Release with semantic-release
|
||||
id: semantic-release
|
||||
run: npx --no-install semantic-release
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
nix-check:
|
||||
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:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -211,3 +211,5 @@ sketch
|
||||
package-lock.json
|
||||
storybook-static/
|
||||
package/
|
||||
|
||||
.direnv/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
@@ -1,3 +1,55 @@
|
||||
## [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
|
||||
10
README.md
10
README.md
@@ -26,8 +26,9 @@
|
||||
## Toolchain
|
||||
|
||||
- React v20.9.0 (LTS)
|
||||
- TypeScript
|
||||
- Vite 5
|
||||
- TypeScript v5.x
|
||||
- Vite v5.x
|
||||
- pnpm v10.x
|
||||
- UnoCSS
|
||||
- ESLint
|
||||
- Prettier
|
||||
@@ -184,8 +185,9 @@ We maintain a strict code of conduct. By contributing, you agree to adhere to th
|
||||
Special thanks to the developers and contributors behind these amazing tools and libraries:
|
||||
|
||||
- React v20.9.0 (LTS)
|
||||
- TypeScript
|
||||
- Vite 5
|
||||
- TypeScript v5.x
|
||||
- Vite v5.x
|
||||
- pnpm v10.x
|
||||
- UnoCSS
|
||||
- ESLint
|
||||
- Prettier
|
||||
|
||||
0
docs/WebSocket-Implementation-Tutorial.md
Normal file
0
docs/WebSocket-Implementation-Tutorial.md
Normal file
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 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
43
flake.nix
Normal 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;
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
10
gulpfile.js
10
gulpfile.js
@@ -35,9 +35,13 @@ function removeExtraDatabaseDir(cb) {
|
||||
// Instrument with Sentry
|
||||
// Make sure sentry is configured https://docs.sentry.io/platforms/javascript/sourcemaps/uploading/typescript/#2-configure-sentry-cli
|
||||
async function instrumentWithSentry(cb) {
|
||||
await exec(`sentry-cli sourcemaps inject ${DIST_DIR}`);
|
||||
await exec(`sentry-cli sourcemaps upload ${DIST_DIR}`);
|
||||
log('Sentry instrumentation completed.');
|
||||
if (process.env.SENTRY_ENV && process.env.SENTRY_ENV !== 'development') {
|
||||
await exec(`sentry-cli sourcemaps inject ${DIST_DIR}`);
|
||||
await exec(`sentry-cli sourcemaps upload ${DIST_DIR}`);
|
||||
log('Sentry instrumentation completed.');
|
||||
} else {
|
||||
logWarn('Skipping uploading/creating Sentry source maps. (development build)');
|
||||
}
|
||||
|
||||
cb();
|
||||
}
|
||||
|
||||
18
package.json
18
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ut-registration-plus",
|
||||
"displayName": "UT Registration Plus",
|
||||
"version": "2.1.1",
|
||||
"version": "2.2.2",
|
||||
"description": "UT Registration Plus is a Chrome extension that allows students to easily register for classes.",
|
||||
"private": true,
|
||||
"homepage": "https://github.com/Longhorn-Developers/UT-Registration-Plus",
|
||||
@@ -9,8 +9,13 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build:dev": "tsc && NODE_ENV='development' vite build --mode development",
|
||||
"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:fix": "prettier src --write",
|
||||
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives",
|
||||
@@ -27,6 +32,7 @@
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@date-fns/tz": "^1.2.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -35,11 +41,13 @@
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"@phosphor-icons/react": "^2.1.7",
|
||||
"@sentry/react": "^8.55.0",
|
||||
"@tanstack/react-query": "^5.69.0",
|
||||
"@unocss/vite": "^0.63.6",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"chrome-extension-toolkit": "^0.0.54",
|
||||
"clsx": "^2.1.1",
|
||||
"conventional-changelog": "^6.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"highcharts": "^11.4.8",
|
||||
"highcharts-react-official": "^3.2.1",
|
||||
"html-to-image": "^1.11.13",
|
||||
@@ -57,6 +65,7 @@
|
||||
"sql.js": "1.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.29.7",
|
||||
"@chromatic-com/storybook": "^2.0.2",
|
||||
"@commitlint/cli": "^19.7.1",
|
||||
"@commitlint/config-conventional": "^19.7.1",
|
||||
@@ -139,7 +148,7 @@
|
||||
"unocss": "^0.63.6",
|
||||
"unocss-preset-primitives": "0.0.2-beta.1",
|
||||
"unplugin-icons": "^0.19.3",
|
||||
"vite": "^5.4.14",
|
||||
"vite": "^5.4.20",
|
||||
"vite-plugin-inspect": "^0.8.9",
|
||||
"vitest": "^2.1.9"
|
||||
},
|
||||
@@ -157,6 +166,7 @@
|
||||
}
|
||||
},
|
||||
"volta": {
|
||||
"node": "20.9.0"
|
||||
"node": "20.19.4",
|
||||
"pnpm": "10.14.0"
|
||||
}
|
||||
}
|
||||
|
||||
566
pnpm-lock.yaml
generated
566
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -30,7 +30,7 @@ interface Props {
|
||||
* @returns A promise that resolves when the changelog is generated.
|
||||
* @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 {
|
||||
// Run the conventional-changelog command to generate changelog
|
||||
const { stdout, stderr } = await execPromise(
|
||||
|
||||
@@ -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
BIN
src/assets/LD-icon-new.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 188 KiB |
@@ -8,15 +8,27 @@ const splashText: string[] = [
|
||||
'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.",
|
||||
"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.',
|
||||
"The year is 2055 and Welch still isn't finished.",
|
||||
'Motivation dropping faster than ur GPA',
|
||||
'No Work Happens On PCL 5th Floor.',
|
||||
'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',
|
||||
'mAke iT yOuR tExAS',
|
||||
'change yOur slogan',
|
||||
@@ -24,7 +36,7 @@ const splashText: string[] = [
|
||||
'Does McCombs teach Parseltongue?',
|
||||
'No Cruce Enfrente Del Bus.',
|
||||
'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',
|
||||
'To hike to Kins or not to hike to Kins...',
|
||||
"C'mon you Longhorns! You want to study forever?",
|
||||
@@ -34,15 +46,11 @@ const splashText: string[] = [
|
||||
"The Block of Butter incident of '22",
|
||||
'Begun, the midterms have.',
|
||||
'You must construct additional schedules',
|
||||
"Arrows of Christ vs Church of Scientology was the crossover we didn't know we needed",
|
||||
'THE WALK SIGN IS ON TO CROSS GUADALUPE AND 21ST',
|
||||
'Pay attention. Might learn something.',
|
||||
'Long ago, apartment rates lived together in harmony. Then, everything changed when American Campus Communities Inc attacked.',
|
||||
'Roll for Initiative!',
|
||||
'The line at the on-campus Starbucks is longer than your course waitlist.',
|
||||
'The weather changes more often than your class schedule.',
|
||||
'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.",
|
||||
"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?",
|
||||
@@ -59,23 +67,15 @@ const splashText: string[] = [
|
||||
'follow @sghsri!',
|
||||
'Officially part of the SEC',
|
||||
'Planner is now acquired by Plus',
|
||||
'Longhorn-Developers is the best UT Student Org',
|
||||
'Longhorn Developers is the best UT Student Org',
|
||||
'The Eiffel Tower is the UT Tower of Paris',
|
||||
'A pen and paper is old fashioned, but sometimes old ways are best',
|
||||
'A heart is like bedrock, destroyable only by cheating',
|
||||
'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. 🧢`,
|
||||
"He's a CS Major, but he showers regularly. 🧢",
|
||||
'A CS major walks into a bar. The bar is empty because it is a CS major.',
|
||||
'UT Registration Plus - The only thing that can make registration worse is not having it',
|
||||
'UT Registration Plus - We make registration slightly less painful. Slightly.',
|
||||
'UT Registration Plus - Do you really want to figure out which professors will ruin your GPA by yourself?',
|
||||
'Ayo tf is a memory leak',
|
||||
"lowkey we never thought we'd get this far, how tf are 60k of you people using this",
|
||||
"Ayo what's is a memory leak",
|
||||
"lowkey we never thought we'd get this far, how are 60k of you people using this",
|
||||
"dang we're really out here making a splash",
|
||||
"We'd make a joke about A&M, but we're not sure they can read",
|
||||
"We've only caused one or two outages, we swear!",
|
||||
@@ -113,8 +113,7 @@ const splashText: string[] = [
|
||||
"Stop trying to make UTRP happen, it's not going to happen!",
|
||||
'Befriend the raccoons on campus',
|
||||
`It's ${new Date().toLocaleString('en-US', { month: 'long', day: 'numeric' })} and OU still sucks`,
|
||||
'As seen on TV! ',
|
||||
"Should you major in Compsci? well, here's a better question. do you wanna have a bad time?",
|
||||
'As seen on TV!',
|
||||
];
|
||||
|
||||
export default splashText;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { ExtensionStore } from '@shared/storage/ExtensionStore';
|
||||
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
||||
|
||||
import createSchedule from '../lib/createSchedule';
|
||||
|
||||
/**
|
||||
* Called when the extension is updated (or when the extension is reloaded in development mode)
|
||||
@@ -8,4 +11,11 @@ export default async function onUpdate() {
|
||||
version: chrome.runtime.getManifest().version,
|
||||
lastUpdate: Date.now(),
|
||||
});
|
||||
|
||||
const schedules = await UserScheduleStore.get('schedules');
|
||||
|
||||
// By invariant, there must always be at least one schedule
|
||||
if (schedules.length === 0) {
|
||||
createSchedule('Schedule 1');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,5 +36,14 @@ export default async function createSchedule(scheduleName: string) {
|
||||
schedules.push(newSchedule);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,11 @@ export default async function deleteSchedule(scheduleId: string): Promise<string
|
||||
schedules.splice(scheduleIndex, 1);
|
||||
await UserScheduleStore.set('schedules', schedules);
|
||||
|
||||
// By invariant, there must always be at least one schedule
|
||||
if (schedules.length === 0) {
|
||||
createSchedule('Schedule 1');
|
||||
}
|
||||
|
||||
let newActiveIndex = activeIndex;
|
||||
if (scheduleIndex < activeIndex) {
|
||||
newActiveIndex = activeIndex - 1;
|
||||
|
||||
@@ -31,5 +31,9 @@ export default async function duplicateSchedule(scheduleId: string): Promise<str
|
||||
} satisfies typeof schedule);
|
||||
|
||||
await UserScheduleStore.set('schedules', schedules);
|
||||
|
||||
// Automatically switch to the duplicated schedule
|
||||
await UserScheduleStore.set('activeIndex', scheduleIndex + 1);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import Calendar from '@views/components/calendar/Calendar';
|
||||
import DialogProvider from '@views/components/common/DialogProvider/DialogProvider';
|
||||
import ExtensionRoot from '@views/components/common/ExtensionRoot/ExtensionRoot';
|
||||
import { MigrationDialog } from '@views/components/common/MigrationDialog';
|
||||
import { WhatsNewDialog } from '@views/components/common/WhatsNewPopup';
|
||||
import SentryProvider from '@views/contexts/SentryContext';
|
||||
import { MessageListener } from 'chrome-extension-toolkit';
|
||||
import useKC_DABR_WASM from 'kc-dabr-wasm';
|
||||
@@ -34,6 +35,7 @@ export default function CalendarMain() {
|
||||
<ExtensionRoot className='h-full w-full'>
|
||||
<DialogProvider>
|
||||
<MigrationDialog />
|
||||
<WhatsNewDialog />
|
||||
<Calendar />
|
||||
</DialogProvider>
|
||||
</ExtensionRoot>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import CourseCatalogMain from '@views/components/CourseCatalogMain';
|
||||
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 React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
@@ -25,3 +27,11 @@ if (support === SiteSupport.COURSE_CATALOG_DETAILS || support === SiteSupport.CO
|
||||
if (support === SiteSupport.MY_UT) {
|
||||
renderComponent(InjectedButton);
|
||||
}
|
||||
|
||||
if (support === SiteSupport.COURSE_CATALOG_SEARCH) {
|
||||
renderComponent(DaysCheckbox);
|
||||
}
|
||||
|
||||
if (support === SiteSupport.COURSE_CATALOG_KWS) {
|
||||
renderComponent(ShadedResults);
|
||||
}
|
||||
|
||||
@@ -8,11 +8,14 @@ interface IExtensionStore {
|
||||
version: string;
|
||||
/** When was the last update */
|
||||
lastUpdate: number;
|
||||
/** The last version of the "What's New" popup that was shown to the user */
|
||||
lastWhatsNewPopupVersion: number;
|
||||
}
|
||||
|
||||
export const ExtensionStore = createLocalStore<IExtensionStore>({
|
||||
version: chrome.runtime.getManifest().version,
|
||||
lastUpdate: Date.now(),
|
||||
lastWhatsNewPopupVersion: 0,
|
||||
});
|
||||
|
||||
debugStore({ ExtensionStore });
|
||||
|
||||
@@ -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 */
|
||||
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>({
|
||||
@@ -26,6 +32,8 @@ export const OptionsStore = createSyncStore<IOptionsStore>({
|
||||
enableScrollToLoad: true,
|
||||
enableDataRefreshing: false,
|
||||
alwaysOpenCalendarInNewTab: false,
|
||||
showCalendarSidebar: true,
|
||||
showUTDiningPromo: true,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -40,6 +48,8 @@ export const initSettings = async () =>
|
||||
enableScrollToLoad: await OptionsStore.get('enableScrollToLoad'),
|
||||
enableDataRefreshing: await OptionsStore.get('enableDataRefreshing'),
|
||||
alwaysOpenCalendarInNewTab: await OptionsStore.get('alwaysOpenCalendarInNewTab'),
|
||||
showCalendarSidebar: await OptionsStore.get('showCalendarSidebar'),
|
||||
showUTDiningPromo: await OptionsStore.get('showUTDiningPromo'),
|
||||
}) satisfies IOptionsStore;
|
||||
|
||||
// Clothing retailer right
|
||||
|
||||
@@ -44,7 +44,12 @@ export type Semester = {
|
||||
export class Course {
|
||||
/** Every course has a uniqueId within UT's registrar system corresponding to each course section */
|
||||
uniqueId!: number;
|
||||
/** This is the course number for a course, i.e CS 314 would be 314, MAL 306H would be 306H */
|
||||
/**
|
||||
* This is the course number for a course, i.e CS 314 would be 314, MAL 306H would be 306H.
|
||||
* UT prefixes summer courses with f, s, n, or w:
|
||||
* [f]irst term, [s]econd term, [n]ine week term, [w]hole term.
|
||||
* So, the first term of PSY 301 over the summer would be 'f301'
|
||||
*/
|
||||
number!: string;
|
||||
/** The full name of the course, i.e. CS 314 Data Structures and Algorithms */
|
||||
fullName!: string;
|
||||
@@ -91,6 +96,46 @@ export class Course {
|
||||
}
|
||||
this.colors = course.colors ? structuredClone(course.colors) : getCourseColors('emerald', 500);
|
||||
this.core = course.core ?? [];
|
||||
if (course.semester.season === 'Summer') {
|
||||
// A bug from and old version put the summer term in the course,
|
||||
// so we need to handle that case
|
||||
const { department, number } = Course.cleanSummerTerm(course.department, course.number);
|
||||
this.department = department;
|
||||
this.number = number;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Due to a bug in an older version, the summer term was included in the course department code,
|
||||
* instead of the course number.
|
||||
* UT prefixes summer courses with f, s, n, or w:
|
||||
* [f]irst term, [s]econd term, [n]ine week term, [w]hole term
|
||||
*
|
||||
* @param department - The course department code, like 'C S'
|
||||
* @param number - The course number, like '314H'
|
||||
* @returns The properly formatted department and course number
|
||||
* @example
|
||||
* ```ts
|
||||
* cleanSummerTerm('C S', '314H') // { department: 'C S', number: '314H' }
|
||||
* cleanSummerTerm('P R', 'f378') // { department: 'P R', number: 'f378' }
|
||||
* cleanSummerTerm('P R f', '378') // { department: 'P R', number: 'f378' }
|
||||
* cleanSummerTerm('P S', 'n303') // { department: 'P S', number: 'n303' }
|
||||
* cleanSummerTerm('P S n', '303') // { department: 'P S', number: 'n303' }
|
||||
* ```
|
||||
*/
|
||||
static cleanSummerTerm(department: string, number: string): { department: string; number: string } {
|
||||
// UT prefixes summer courses with f, s, n, or w:
|
||||
// [f]irst term, [s]econd term, [n]ine week term, [w]hole term
|
||||
const summerTerm = department.match(/[fsnw]$/);
|
||||
|
||||
if (!summerTerm) {
|
||||
return { department, number };
|
||||
}
|
||||
|
||||
return {
|
||||
department: department.slice(0, -1).trim(),
|
||||
number: summerTerm[0] + number,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,6 +156,18 @@ export class Course {
|
||||
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns The course number without the summer term
|
||||
* @example
|
||||
* ```ts
|
||||
* const c = new Course({ number: 'f301', ... });
|
||||
* c.getNumberWithoutTerm() // '301'
|
||||
* ```
|
||||
*/
|
||||
getNumberWithoutTerm(): string {
|
||||
return this.number.replace(/^\D/, ''); // Remove nondigit at start, if it exists
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
57
src/shared/types/tests/Course.test.ts
Normal file
57
src/shared/types/tests/Course.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { Course } from '../Course';
|
||||
|
||||
describe('Course::cleanSummerTerm', () => {
|
||||
it("shouldn't affect already cleaned summer terms", () => {
|
||||
const inputs = [
|
||||
['C S', '314H'],
|
||||
['P R', 'f378'],
|
||||
['P S', 'f303'],
|
||||
['WGS', 's301'],
|
||||
['S W', 'n360K'],
|
||||
['GOV', 'w312L'],
|
||||
['J', 's311F'],
|
||||
['J S', '311F'],
|
||||
] as const;
|
||||
const expected = [
|
||||
{ department: 'C S', number: '314H' },
|
||||
{ department: 'P R', number: 'f378' },
|
||||
{ department: 'P S', number: 'f303' },
|
||||
{ department: 'WGS', number: 's301' },
|
||||
{ department: 'S W', number: 'n360K' },
|
||||
{ department: 'GOV', number: 'w312L' },
|
||||
{ department: 'J', number: 's311F' },
|
||||
{ department: 'J S', number: '311F' },
|
||||
];
|
||||
|
||||
const results = inputs.map(input => Course.cleanSummerTerm(input[0], input[1]));
|
||||
|
||||
expect(results).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should move summer term indicator to course number', () => {
|
||||
const inputs = [
|
||||
['P R f', '378'],
|
||||
['P S f', '303'],
|
||||
['WGS s', '301'],
|
||||
['S W n', '360K'],
|
||||
['GOV w', '312L'],
|
||||
['J s', '311F'],
|
||||
['J S', '311F'],
|
||||
] as const;
|
||||
const expected = [
|
||||
{ department: 'P R', number: 'f378' },
|
||||
{ department: 'P S', number: 'f303' },
|
||||
{ department: 'WGS', number: 's301' },
|
||||
{ department: 'S W', number: 'n360K' },
|
||||
{ department: 'GOV', number: 'w312L' },
|
||||
{ department: 'J', number: 's311F' },
|
||||
{ department: 'J S', number: '311F' },
|
||||
];
|
||||
|
||||
const results = inputs.map(input => Course.cleanSummerTerm(input[0], input[1]));
|
||||
|
||||
expect(results).toEqual(expected);
|
||||
});
|
||||
});
|
||||
19
src/shared/util/appUrls.ts
Normal file
19
src/shared/util/appUrls.ts
Normal 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();
|
||||
@@ -48,3 +48,22 @@ export const ellipsify = (input: string, chars: number): string => {
|
||||
}
|
||||
return ellipisifed;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stringifies a list of items in English format.
|
||||
*
|
||||
* @param items - The list of items to stringify.
|
||||
* @returns A string representation of the list in English format.
|
||||
* @example
|
||||
* englishStringifyList([]) // ''
|
||||
* englishStringifyList(['Alice']) // 'Alice'
|
||||
* englishStringifyList(['Alice', 'Bob']) // 'Alice and Bob'
|
||||
* englishStringifyList(['Alice', 'Bob', 'Charlie']) // 'Alice, Bob, and Charlie'
|
||||
*/
|
||||
export const englishStringifyList = (items: readonly string[]): string => {
|
||||
if (items.length === 0) return '';
|
||||
if (items.length === 1) return items[0]!;
|
||||
if (items.length === 2) return `${items[0]} and ${items[1]}`;
|
||||
|
||||
return `${items.slice(0, -1).join(', ')}, and ${items.at(-1)}`;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { capitalize, capitalizeFirstLetter, ellipsify } from '@shared/util/string';
|
||||
import { capitalize, capitalizeFirstLetter, ellipsify, englishStringifyList } from '@shared/util/string';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// TODO: Fix `string.ts` and `string.test.ts` to make the tests pass
|
||||
@@ -54,3 +54,49 @@ describe('ellipsify', () => {
|
||||
expect(ellipsify('', 5)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('englishStringifyList', () => {
|
||||
it('should handle an empty array', () => {
|
||||
const data = [] satisfies string[];
|
||||
const result = englishStringifyList(data);
|
||||
const expected = '';
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle 1 element', () => {
|
||||
const data = ['Alice'] satisfies string[];
|
||||
const result = englishStringifyList(data);
|
||||
const expected = 'Alice';
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle 2 elements', () => {
|
||||
const data = ['Alice', 'Bob'] satisfies string[];
|
||||
const result = englishStringifyList(data);
|
||||
const expected = 'Alice and Bob';
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle 3 elements', () => {
|
||||
const data = ['Alice', 'Bob', 'Charlie'] satisfies string[];
|
||||
const result = englishStringifyList(data);
|
||||
const expected = 'Alice, Bob, and Charlie';
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle n elements', () => {
|
||||
const testcases = [
|
||||
{ data: [], expected: '' },
|
||||
{ data: ['foo'], expected: 'foo' },
|
||||
{ data: ['foo', 'bar'], expected: 'foo and bar' },
|
||||
{ data: ['foo', 'bar', 'baz'], expected: 'foo, bar, and baz' },
|
||||
{ data: ['a', 'b', 'c', 'd'], expected: 'a, b, c, and d' },
|
||||
{ data: 'abcdefghijk'.split(''), expected: 'a, b, c, d, e, f, g, h, i, j, and k' },
|
||||
] satisfies { data: string[]; expected: string }[];
|
||||
|
||||
for (const { data, expected } of testcases) {
|
||||
const result = englishStringifyList(data);
|
||||
expect(result).toBe(expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,11 +3,13 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('sleep', () => {
|
||||
it('should resolve after the specified number of milliseconds', async () => {
|
||||
const start = Date.now();
|
||||
const start = performance.now();
|
||||
const milliseconds = 1000;
|
||||
await sleep(milliseconds);
|
||||
const end = Date.now();
|
||||
const end = performance.now();
|
||||
const elapsed = end - start;
|
||||
expect(elapsed).toBeGreaterThanOrEqual(milliseconds);
|
||||
// Flaky test due to JS's lack of precision in setTimeout,
|
||||
// so we allow for a 1ms difference
|
||||
expect(elapsed).toBeGreaterThanOrEqual(milliseconds - 1);
|
||||
});
|
||||
});
|
||||
|
||||
14
src/stories/components/DiningAppPromo.stories.tsx
Normal file
14
src/stories/components/DiningAppPromo.stories.tsx
Normal 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 = {};
|
||||
@@ -73,7 +73,7 @@ const generateCourses = (count: number): Course[] => {
|
||||
|
||||
const exampleCourses = generateCourses(numberOfCourses);
|
||||
|
||||
type CourseWithId = Course & BaseItem;
|
||||
type CourseWithId = { course: Course } & BaseItem;
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Common/SortableList',
|
||||
@@ -91,11 +91,10 @@ export const Default: Story = {
|
||||
args: {
|
||||
draggables: exampleCourses.map(course => ({
|
||||
id: course.uniqueId,
|
||||
...course,
|
||||
getConflicts: course.getConflicts,
|
||||
course,
|
||||
})),
|
||||
onChange: () => {},
|
||||
renderItem: course => <PopupCourseBlock key={course.id} course={course} colors={course.colors} />,
|
||||
renderItem: ({ id, course }) => <PopupCourseBlock key={id} course={course} colors={course.colors} />,
|
||||
},
|
||||
render: args => (
|
||||
<div className='h-3xl w-3xl transform-none'>
|
||||
|
||||
@@ -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'],
|
||||
};
|
||||
38
src/stories/components/WhatsNewPopup.stories.tsx
Normal file
38
src/stories/components/WhatsNewPopup.stories.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -174,3 +174,137 @@ export const mikeScottCS314Schedule: UserSchedule = new UserSchedule({
|
||||
hours: 3,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
|
||||
export const multiMeetingMultiInstructorCourse: Course = new Course({
|
||||
colors: {
|
||||
primaryColor: '#ef4444',
|
||||
secondaryColor: '#991b1b',
|
||||
},
|
||||
core: [],
|
||||
courseName: '44-REPORTING TEXAS',
|
||||
creditHours: 3,
|
||||
department: 'J',
|
||||
description: [
|
||||
"Contemporary social, professional, and intellectual concerns with the practice of journalism. Students work as online reporters, photographers, and editors for the School of Journalism's Reporting Texas Web site.",
|
||||
'Prerequisite: Graduate standing; additional prerequisites vary with the topic.',
|
||||
'Designed to accommodate 35 or fewer students. Course number may be repeated for credit when the topics vary.',
|
||||
],
|
||||
flags: [],
|
||||
fullName: 'J 395 44-REPORTING TEXAS',
|
||||
instructionMode: 'In Person',
|
||||
instructors: [
|
||||
{
|
||||
firstName: 'JOHN',
|
||||
fullName: 'SCHWARTZ, JOHN R',
|
||||
lastName: 'SCHWARTZ',
|
||||
middleInitial: 'R',
|
||||
},
|
||||
{
|
||||
firstName: 'JOHN',
|
||||
fullName: 'BRIDGES, JOHN A III',
|
||||
lastName: 'BRIDGES',
|
||||
middleInitial: 'A',
|
||||
},
|
||||
],
|
||||
isReserved: true,
|
||||
number: '395',
|
||||
schedule: {
|
||||
meetings: [
|
||||
{
|
||||
days: ['Tuesday', 'Thursday'],
|
||||
endTime: 660,
|
||||
location: {
|
||||
building: 'CMA',
|
||||
room: '6.146',
|
||||
},
|
||||
startTime: 570,
|
||||
},
|
||||
{
|
||||
days: ['Friday'],
|
||||
endTime: 960,
|
||||
location: {
|
||||
building: 'DMC',
|
||||
room: '3.208',
|
||||
},
|
||||
startTime: 780,
|
||||
},
|
||||
],
|
||||
},
|
||||
scrapedAt: 1742491957535,
|
||||
semester: {
|
||||
code: '20259',
|
||||
season: 'Fall',
|
||||
year: 2025,
|
||||
},
|
||||
status: 'OPEN',
|
||||
uniqueId: 10335,
|
||||
url: 'https://utdirect.utexas.edu/apps/registrar/course_schedule/20259/10335/',
|
||||
});
|
||||
|
||||
export const multiMeetingMultiInstructorSchedule: UserSchedule = new UserSchedule({
|
||||
courses: [multiMeetingMultiInstructorCourse],
|
||||
id: 'mmmis',
|
||||
name: 'Multi Meeting Multi Instructor Schedule',
|
||||
hours: 3,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
|
||||
export const chatterjeeCS429Course: Course = new Course({
|
||||
colors: {
|
||||
primaryColor: '#0284c7',
|
||||
secondaryColor: '#0c4a6e',
|
||||
},
|
||||
core: [],
|
||||
courseName: 'COMP ORGANIZATN AND ARCH',
|
||||
creditHours: 4,
|
||||
department: 'C S',
|
||||
description: [
|
||||
'Restricted to computer science majors.',
|
||||
'An introduction to low-level computer design ranging from the basics of digital design to the hardware/software interface for application programs. Includes basic systems principles of pipelining and caching, and requires writing and understanding programs at multiple levels.',
|
||||
'Computer Science 429 and 429H may not both be counted.',
|
||||
'Prerequisite: The following courses with a grade of at least C-: Computer Science 311 or 311H; and Computer Science 314 or 314H.',
|
||||
],
|
||||
flags: [],
|
||||
fullName: 'C S 429 COMP ORGANIZATN AND ARCH',
|
||||
instructionMode: 'In Person',
|
||||
instructors: [
|
||||
{
|
||||
firstName: 'SIDDHARTHA',
|
||||
fullName: 'CHATTERJEE, SIDDHARTHA',
|
||||
lastName: 'CHATTERJEE',
|
||||
},
|
||||
],
|
||||
isReserved: true,
|
||||
number: '429',
|
||||
schedule: {
|
||||
meetings: [
|
||||
{
|
||||
days: ['Monday', 'Tuesday', 'Wednesday', 'Thursday'],
|
||||
endTime: 1020,
|
||||
location: {
|
||||
building: 'UTC',
|
||||
room: '3.102',
|
||||
},
|
||||
startTime: 960,
|
||||
},
|
||||
{
|
||||
days: ['Friday'],
|
||||
endTime: 660,
|
||||
location: {
|
||||
building: 'GSB',
|
||||
room: '2.122',
|
||||
},
|
||||
startTime: 540,
|
||||
},
|
||||
],
|
||||
},
|
||||
scrapedAt: 1742496630445,
|
||||
semester: {
|
||||
code: '20259',
|
||||
season: 'Fall',
|
||||
year: 2025,
|
||||
},
|
||||
status: 'OPEN',
|
||||
uniqueId: 54795,
|
||||
url: 'https://utdirect.utexas.edu/apps/registrar/course_schedule/20259/54795/',
|
||||
});
|
||||
|
||||
@@ -13,7 +13,9 @@ import { CourseCatalogScraper } from '@views/lib/CourseCatalogScraper';
|
||||
import getCourseTableRows from '@views/lib/getCourseTableRows';
|
||||
import type { SiteSupportType } from '@views/lib/getSiteSupport';
|
||||
import { populateSearchInputs } from '@views/lib/populateSearchInputs';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import DialogProvider from './common/DialogProvider/DialogProvider';
|
||||
|
||||
interface Props {
|
||||
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 [showPopup, setShowPopup] = useState(false);
|
||||
const [enableScrollToLoad, setEnableScrollToLoad] = useState<boolean>(false);
|
||||
const prevCourseTitleRef = useRef<string | null>(null);
|
||||
const tbody = document.querySelector('table tbody')!;
|
||||
|
||||
useEffect(() => {
|
||||
populateSearchInputs();
|
||||
@@ -43,6 +47,9 @@ export default function CourseCatalogMain({ support }: Props): JSX.Element | nul
|
||||
const ccs = new CourseCatalogScraper(support);
|
||||
const scrapedRows = ccs.scrape(tableRows, true);
|
||||
setRows(scrapedRows);
|
||||
prevCourseTitleRef.current =
|
||||
scrapedRows.findLast(row => row.course === null)?.element.querySelector('.course_header')?.textContent ??
|
||||
null;
|
||||
}, [support]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -51,8 +58,17 @@ export default function CourseCatalogMain({ support }: Props): JSX.Element | nul
|
||||
|
||||
const addRows = (newRows: ScrapedRow[]) => {
|
||||
newRows.forEach(row => {
|
||||
document.querySelector('table tbody')!.appendChild(row.element);
|
||||
const courseTitle = row.element.querySelector('.course_header')?.textContent ?? null;
|
||||
if (row.course === null) {
|
||||
if (courseTitle !== prevCourseTitleRef.current) {
|
||||
tbody.appendChild(row.element);
|
||||
prevCourseTitleRef.current = courseTitle;
|
||||
}
|
||||
} else {
|
||||
tbody.appendChild(row.element);
|
||||
}
|
||||
});
|
||||
|
||||
setRows([...rows, ...newRows]);
|
||||
};
|
||||
|
||||
@@ -68,28 +84,30 @@ export default function CourseCatalogMain({ support }: Props): JSX.Element | nul
|
||||
|
||||
return (
|
||||
<ExtensionRoot>
|
||||
<NewSearchLink />
|
||||
<RecruitmentBanner />
|
||||
<TableHead>Plus</TableHead>
|
||||
{rows.map(
|
||||
row =>
|
||||
row.course && (
|
||||
<TableRow
|
||||
key={row.course.uniqueId}
|
||||
row={row}
|
||||
isSelected={row.course.uniqueId === selectedCourse?.uniqueId}
|
||||
activeSchedule={activeSchedule}
|
||||
onClick={handleRowButtonClick(row.course)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<CourseCatalogInjectedPopup
|
||||
course={selectedCourse!} // always defined when showPopup is true
|
||||
show={showPopup}
|
||||
onClose={() => setShowPopup(false)}
|
||||
afterLeave={() => setSelectedCourse(null)}
|
||||
/>
|
||||
{enableScrollToLoad && <AutoLoad addRows={addRows} />}
|
||||
<DialogProvider>
|
||||
<NewSearchLink />
|
||||
<RecruitmentBanner />
|
||||
<TableHead>Plus</TableHead>
|
||||
{rows.map(
|
||||
row =>
|
||||
row.course && (
|
||||
<TableRow
|
||||
key={row.course.uniqueId}
|
||||
row={row}
|
||||
isSelected={row.course.uniqueId === selectedCourse?.uniqueId}
|
||||
activeSchedule={activeSchedule}
|
||||
onClick={handleRowButtonClick(row.course)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<CourseCatalogInjectedPopup
|
||||
course={selectedCourse!} // always defined when showPopup is true
|
||||
show={showPopup}
|
||||
onClose={() => setShowPopup(false)}
|
||||
afterLeave={() => setSelectedCourse(null)}
|
||||
/>
|
||||
{enableScrollToLoad && <AutoLoad addRows={addRows} />}
|
||||
</DialogProvider>
|
||||
</ExtensionRoot>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,11 +66,19 @@ export default function PopupMain(): JSX.Element {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const randomIndex = Math.floor(Math.random() * splashText.length);
|
||||
setFunny(
|
||||
splashText[randomIndex] ?? 'If you are seeing this, something has gone horribly wrong behind the scenes.'
|
||||
);
|
||||
}, []);
|
||||
setFunny(prevFunny => {
|
||||
// Ensure that the next splash text is not the same as the previous one
|
||||
const splashTextWithoutCurrent = splashText.filter(text => text !== prevFunny);
|
||||
const randomIndex = Math.floor(Math.random() * splashTextWithoutCurrent.length);
|
||||
|
||||
return (
|
||||
splashTextWithoutCurrent[randomIndex] ??
|
||||
'If you are seeing this, something has gone horribly wrong behind the scenes.'
|
||||
);
|
||||
});
|
||||
|
||||
// Generate a new splash text every time the active schedule changes
|
||||
}, [activeSchedule.id]);
|
||||
|
||||
const handleOpenOptions = async () => {
|
||||
const url = chrome.runtime.getURL('/options.html');
|
||||
@@ -147,15 +155,14 @@ export default function PopupMain(): JSX.Element {
|
||||
<SortableList
|
||||
draggables={activeSchedule.courses.map(course => ({
|
||||
id: course.uniqueId,
|
||||
...course,
|
||||
getConflicts: course.getConflicts,
|
||||
course,
|
||||
}))}
|
||||
onChange={reordered => {
|
||||
activeSchedule.courses = reordered.map(({ id: _id, ...course }) => course);
|
||||
activeSchedule.courses = reordered.map(({ course }) => course);
|
||||
replaceSchedule(getActiveSchedule(), activeSchedule);
|
||||
}}
|
||||
renderItem={course => (
|
||||
<PopupCourseBlock key={course.id} course={course} colors={course.colors} />
|
||||
renderItem={({ id, course }) => (
|
||||
<PopupCourseBlock key={id} course={course} colors={course.colors} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Sidebar } from '@phosphor-icons/react';
|
||||
import type { CalendarTabMessages } from '@shared/messages/CalendarMessages';
|
||||
import { OptionsStore } from '@shared/storage/OptionsStore';
|
||||
import type { Course } from '@shared/types/Course';
|
||||
import { CRX_PAGES } from '@shared/types/CRXPages';
|
||||
import { openReportWindow } from '@shared/util/openReportWindow';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import CalendarBottomBar from '@views/components/calendar/CalendarBottomBar';
|
||||
import CalendarGrid from '@views/components/calendar/CalendarGrid';
|
||||
import CalendarHeader from '@views/components/calendar/CalendarHeader/CalendarHeader';
|
||||
@@ -13,8 +15,10 @@ import CourseCatalogInjectedPopup from '@views/components/injected/CourseCatalog
|
||||
import { CalendarContext } from '@views/contexts/CalendarContext';
|
||||
import useCourseFromUrl from '@views/hooks/useCourseFromUrl';
|
||||
import { useFlattenedCourseSchedule } from '@views/hooks/useFlattenedCourseSchedule';
|
||||
import useWhatsNewPopUp from '@views/hooks/useWhatsNew';
|
||||
import { MessageListener } from 'chrome-extension-toolkit';
|
||||
import clsx from 'clsx';
|
||||
import type { ReactNode } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import OutwardArrowIcon from '~icons/material-symbols/arrow-outward';
|
||||
@@ -23,17 +27,39 @@ import { Button } from '../common/Button';
|
||||
import { LargeLogo } from '../common/LogoIcon';
|
||||
import Text from '../common/Text/Text';
|
||||
import CalendarFooter from './CalendarFooter';
|
||||
import DiningAppPromo from './DiningAppPromo';
|
||||
|
||||
/**
|
||||
* Calendar page component
|
||||
*/
|
||||
export default function Calendar(): JSX.Element {
|
||||
export default function Calendar(): ReactNode {
|
||||
const { courseCells, activeSchedule } = useFlattenedCourseSchedule();
|
||||
const asyncCourseCells = courseCells.filter(block => block.async);
|
||||
const displayBottomBar = asyncCourseCells && asyncCourseCells.length > 0;
|
||||
|
||||
const [course, setCourse] = useState<Course | null>(useCourseFromUrl());
|
||||
|
||||
const [showPopup, setShowPopup] = useState<boolean>(course !== null);
|
||||
const [showSidebar, setShowSidebar] = useState<boolean>(true);
|
||||
const showWhatsNewDialog = useWhatsNewPopUp();
|
||||
|
||||
const [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(() => {
|
||||
const listener = new MessageListener<CalendarTabMessages>({
|
||||
@@ -59,10 +85,19 @@ export default function Calendar(): JSX.Element {
|
||||
if (course) setShowPopup(true);
|
||||
}, [course]);
|
||||
|
||||
useEffect(() => {
|
||||
// Load the user's preference for the promo
|
||||
OptionsStore.get('showUTDiningPromo').then(show => {
|
||||
setShowUTDiningPromo(show);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (isSidebarStatePending) return null;
|
||||
|
||||
return (
|
||||
<CalendarContext.Provider value>
|
||||
<div className='h-full w-full flex flex-col'>
|
||||
<div className='h-screen flex overflow-auto'>
|
||||
<div className='screenshot:calendar-target h-screen flex overflow-auto'>
|
||||
<div
|
||||
className={clsx(
|
||||
'py-spacing-6 relative h-full min-h-screen w-full flex flex-none flex-col justify-between overflow-clip whitespace-nowrap border-r border-ut-offwhite/50 shadow-[2px_0_10px,rgba(214_210_196_/_.1)] motion-safe:duration-300 motion-safe:ease-out-expo motion-safe:transition-[max-width] screenshot:hidden',
|
||||
@@ -97,21 +132,44 @@ export default function Calendar(): JSX.Element {
|
||||
<CalendarSchedules />
|
||||
<Divider orientation='horizontal' size='100%' />
|
||||
<ResourceLinks />
|
||||
<Divider orientation='horizontal' size='100%' />
|
||||
{/* <TeamLinks /> */}
|
||||
<a
|
||||
href={CRX_PAGES.REPORT}
|
||||
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>
|
||||
<Divider orientation='horizontal' size='100%' />
|
||||
{showUTDiningPromo && (
|
||||
<DiningAppPromo
|
||||
onClose={() => {
|
||||
setShowUTDiningPromo(false);
|
||||
OptionsStore.set('showUTDiningPromo', false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className='flex flex-col gap-spacing-3'>
|
||||
<a
|
||||
href={CRX_PAGES.REPORT}
|
||||
className='flex items-center gap-spacing-2 text-ut-burntorange underline-offset-2 hover:underline'
|
||||
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's New!</Text>
|
||||
<OutwardArrowIcon className='h-4 w-4' />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CalendarFooter />
|
||||
@@ -123,7 +181,7 @@ export default function Calendar(): JSX.Element {
|
||||
// scrollbarGutter: 'stable',
|
||||
}
|
||||
}
|
||||
className='h-full flex flex-grow flex-col overflow-x-scroll px-spacing-5'
|
||||
className='z-1 h-full flex flex-grow flex-col overflow-x-scroll [&>*]:px-spacing-5'
|
||||
>
|
||||
<CalendarHeader
|
||||
sidebarOpen={showSidebar}
|
||||
@@ -131,7 +189,11 @@ export default function Calendar(): JSX.Element {
|
||||
setShowSidebar(!showSidebar);
|
||||
}}
|
||||
/>
|
||||
<div className='min-h-2xl min-w-5xl flex-grow overflow-auto pl-spacing-3 pt-spacing-3 screenshot:min-h-xl'>
|
||||
<div
|
||||
className={clsx('min-h-2xl min-w-5xl flex-grow gap-0 pl-spacing-3 screenshot:min-h-xl', {
|
||||
'screenshot:flex-grow-0': displayBottomBar, // html-to-image seems to have a bug with flex-grow
|
||||
})}
|
||||
>
|
||||
<CalendarGrid courseCells={courseCells} setCourse={setCourse} />
|
||||
</div>
|
||||
<CalendarBottomBar courseCells={courseCells} setCourse={setCourse} />
|
||||
|
||||
@@ -3,6 +3,7 @@ import Text from '@views/components/common/Text/Text';
|
||||
import { ColorPickerProvider } from '@views/contexts/ColorPickerContext';
|
||||
import type { CalendarGridCourse } from '@views/hooks/useFlattenedCourseSchedule';
|
||||
import clsx from 'clsx';
|
||||
import type { ReactNode } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import CalendarCourseBlock from './CalendarCourseCell';
|
||||
@@ -18,44 +19,38 @@ type CalendarBottomBarProps = {
|
||||
* @param courses - The list of courses to display in the calendar.
|
||||
* @returns The rendered bottom bar component.
|
||||
*/
|
||||
export default function CalendarBottomBar({ courseCells, setCourse }: CalendarBottomBarProps): JSX.Element {
|
||||
export default function CalendarBottomBar({ courseCells, setCourse }: CalendarBottomBarProps): ReactNode {
|
||||
const asyncCourseCells = courseCells?.filter(block => block.async);
|
||||
const displayCourses = asyncCourseCells && asyncCourseCells.length > 0;
|
||||
|
||||
if (!displayCourses) return null;
|
||||
|
||||
return (
|
||||
<div className='w-full flex pl-spacing-7 pr-spacing-3 pt-spacing-4'>
|
||||
<div
|
||||
className={clsx('flex flex-grow items-center gap-1 text-nowrap', {
|
||||
'py-7.5': !displayCourses,
|
||||
})}
|
||||
>
|
||||
{displayCourses && (
|
||||
<>
|
||||
<Text variant='p' className='text-ut-black uppercase'>
|
||||
Async / Other
|
||||
</Text>
|
||||
<Text variant='h4' className='text-theme-offwhite/50'>
|
||||
—
|
||||
</Text>
|
||||
<div className='inline-flex gap-2.5'>
|
||||
<ColorPickerProvider>
|
||||
{asyncCourseCells.map(block => {
|
||||
const { courseDeptAndInstr, status, className } = block.componentProps;
|
||||
return (
|
||||
<CalendarCourseBlock
|
||||
key={block.course.uniqueId}
|
||||
courseDeptAndInstr={courseDeptAndInstr}
|
||||
status={status}
|
||||
className={clsx(className, 'w-35! h-12.5! items-center')}
|
||||
onClick={() => setCourse(block.course)}
|
||||
blockData={block}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ColorPickerProvider>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className='flex flex-grow items-center gap-1 text-nowrap'>
|
||||
<Text variant='p' className='text-ut-black uppercase'>
|
||||
Async / Other
|
||||
</Text>
|
||||
<Text variant='h4' className='text-theme-offwhite/50'>
|
||||
—
|
||||
</Text>
|
||||
<div className='inline-flex gap-2.5'>
|
||||
<ColorPickerProvider>
|
||||
{asyncCourseCells.map(block => {
|
||||
const { courseDeptAndInstr, status, className } = block.componentProps;
|
||||
return (
|
||||
<CalendarCourseBlock
|
||||
key={block.course.uniqueId}
|
||||
courseDeptAndInstr={courseDeptAndInstr}
|
||||
status={status}
|
||||
className={clsx(className, 'w-35! h-12.5! items-center')}
|
||||
onClick={() => setCourse(block.course)}
|
||||
blockData={block}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ColorPickerProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -27,12 +27,10 @@ export default function HexColorEditor({ hexCode, setHexCode }: HexColorEditorPr
|
||||
const tagColor = pickFontColor(previewColor.slice(1) as `#${string}`);
|
||||
|
||||
const [localHexCode, setLocalHexCode] = React.useState(hexCode);
|
||||
const debouncedSetHexCode = useDebounce((value: string) => setHexCode(value), 500);
|
||||
const debouncedSetHexCode = useDebounce(setHexCode, 500);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hexCode !== localHexCode) {
|
||||
setLocalHexCode(hexCode);
|
||||
}
|
||||
setLocalHexCode(hexCode);
|
||||
}, [hexCode]);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -2,14 +2,18 @@ import type { Course } from '@shared/types/Course';
|
||||
import CalendarCourseCell from '@views/components/calendar/CalendarCourseCell';
|
||||
import Text from '@views/components/common/Text/Text';
|
||||
import { ColorPickerProvider } from '@views/contexts/ColorPickerContext';
|
||||
import { useSentryScope } from '@views/contexts/SentryContext';
|
||||
import type { CalendarGridCourse } from '@views/hooks/useFlattenedCourseSchedule';
|
||||
import React from 'react';
|
||||
import React, { Fragment } from 'react';
|
||||
|
||||
import CalendarCell from './CalendarGridCell';
|
||||
import { calculateCourseCellColumns } from './utils';
|
||||
|
||||
const daysOfWeek = ['MON', 'TUE', 'WED', 'THU', 'FRI'];
|
||||
const hoursOfDay = Array.from({ length: 14 }, (_, index) => index + 8);
|
||||
|
||||
const IS_STORYBOOK = import.meta.env.STORYBOOK;
|
||||
|
||||
interface Props {
|
||||
courseCells?: CalendarGridCourse[];
|
||||
saturdayClass?: boolean;
|
||||
@@ -30,13 +34,13 @@ function makeGridRow(row: number, cols: number): JSX.Element {
|
||||
const hour = hoursOfDay[row]!;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Fragment key={row}>
|
||||
<CalendarHour hour={hour} />
|
||||
<div className='grid-row-span-2 w-4 border-b border-r border-gray-300' />
|
||||
{[...Array(cols).keys()].map(col => (
|
||||
<CalendarCell key={`${row}${col}`} row={row} col={col} />
|
||||
))}
|
||||
</>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,23 +60,40 @@ export default function CalendarGrid({
|
||||
setCourse,
|
||||
}: React.PropsWithChildren<Props>): JSX.Element {
|
||||
return (
|
||||
<div className='grid grid-cols-[auto_auto_repeat(5,1fr)] grid-rows-[auto_repeat(26,1fr)] h-full'>
|
||||
<div className='grid grid-cols-[auto_auto_repeat(5,1fr)] grid-rows-[auto_auto_repeat(27,1fr)] h-full'>
|
||||
{/* Cover top left corner of grid, so time gets cut off at the top of the partial border */}
|
||||
<div className='sticky top-[85px] z-10 col-span-2 h-3 bg-white' />
|
||||
{/* Displaying day labels */}
|
||||
<div />
|
||||
<div className='w-4 border-b border-r border-gray-300' />
|
||||
{daysOfWeek.map(day => (
|
||||
<div className='h-4 flex items-end justify-center border-b border-r border-gray-300 pb-1.5'>
|
||||
<Text key={day} variant='small' className='text-center text-ut-burntorange' as='div'>
|
||||
{day}
|
||||
</Text>
|
||||
<div
|
||||
// Full height with background to prevent grid lines from showing behind
|
||||
className='sticky top-[85px] z-10 row-span-2 h-7 flex flex-col items-end self-start justify-end bg-white'
|
||||
key={day}
|
||||
>
|
||||
{/* Partial border height because that's what Isaiah wants */}
|
||||
<div className='h-4 w-full flex items-end border-b border-r border-gray-300'>
|
||||
{/* Alignment for text */}
|
||||
<div className='h-[calc(1.75rem_-_1px)] w-full flex items-center justify-center'>
|
||||
<Text variant='small' className='text-center text-ut-burntorange' as='div'>
|
||||
{day}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* empty slot, for alignment */}
|
||||
<div />
|
||||
{/* time tick for the first hour */}
|
||||
<div className='h-4 w-4 self-end border-b border-r border-gray-300' />
|
||||
{[...Array(13).keys()].map(i => makeGridRow(i, 5))}
|
||||
<CalendarHour hour={21} />
|
||||
{Array(6)
|
||||
.fill(1)
|
||||
.map(() => (
|
||||
<div className='h-4 flex items-end justify-center border-r border-gray-300' />
|
||||
.map((_, i) => (
|
||||
// Key suppresses warning about duplicate keys,
|
||||
// and index is fine because it doesn't change between renders
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<div key={i} className='h-4 flex items-end justify-center border-r border-gray-300' />
|
||||
))}
|
||||
<ColorPickerProvider>
|
||||
{courseCells && <AccountForCourseConflicts courseCells={courseCells} setCourse={setCourse} />}
|
||||
@@ -89,6 +110,12 @@ interface AccountForCourseConflictsProps {
|
||||
// TODO: Possibly refactor to be more concise
|
||||
// TODO: Deal with react strict mode (wacky movements)
|
||||
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
|
||||
const days = courseCells.reduce(
|
||||
(acc, cell: CalendarGridCourse) => {
|
||||
@@ -103,31 +130,15 @@ function AccountForCourseConflicts({ courseCells, setCourse }: AccountForCourseC
|
||||
);
|
||||
|
||||
// Check for overlaps within each day and adjust gridColumnIndex and totalColumns
|
||||
Object.values(days).forEach((dayCells: CalendarGridCourse[]) => {
|
||||
// Sort by start time to ensure proper columnIndex assignment
|
||||
dayCells.sort((a, b) => a.calendarGridPoint.startIndex - b.calendarGridPoint.startIndex);
|
||||
|
||||
dayCells.forEach((cell, _, arr) => {
|
||||
let columnIndex = 1;
|
||||
cell.totalColumns = 1;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
Object.values(days).forEach((dayCells: CalendarGridCourse[], idx) => {
|
||||
try {
|
||||
calculateCourseCellColumns(dayCells);
|
||||
} catch (error) {
|
||||
console.error(`Error calculating course cell columns ${idx}`, error);
|
||||
if (sentryScope) {
|
||||
sentryScope.captureException(error);
|
||||
}
|
||||
cell.gridColumnStart = columnIndex;
|
||||
cell.gridColumnEnd = columnIndex + 1;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return courseCells
|
||||
|
||||
@@ -17,8 +17,8 @@ function CalendarCell({ row, col }: Props): JSX.Element {
|
||||
<div
|
||||
className='h-full w-full flex items-center border-b border-r border-gray-300'
|
||||
style={{
|
||||
gridColumn: col + 3,
|
||||
gridRow: `${2 * row + 2} / ${2 * row + 4}`,
|
||||
gridColumn: col + 3, // start in the 3rd 1-index column
|
||||
gridRow: `${2 * row + 3} / ${2 * row + 5}`, // Span 2 rows, skip 2 header rows
|
||||
}}
|
||||
>
|
||||
<div className='h-0 w-full border-t border-gray-300/25' />
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
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 { Button } from '@views/components/common/Button';
|
||||
import DialogProvider from '@views/components/common/DialogProvider/DialogProvider';
|
||||
import Divider from '@views/components/common/Divider';
|
||||
import { ExtensionRootWrapper, styleResetClass } from '@views/components/common/ExtensionRoot/ExtensionRoot';
|
||||
import { LargeLogo } from '@views/components/common/LogoIcon';
|
||||
import ScheduleTotalHoursAndCourses from '@views/components/common/ScheduleTotalHoursAndCourses';
|
||||
import useSchedules from '@views/hooks/useSchedules';
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import { saveAsCal, saveCalAsPng } from '../utils';
|
||||
import { handleExportJson, saveAsCal, saveCalAsPng } from '../utils';
|
||||
|
||||
interface CalendarHeaderProps {
|
||||
sidebarOpen?: boolean;
|
||||
@@ -27,7 +28,7 @@ export default function CalendarHeader({ sidebarOpen, onSidebarToggle }: Calenda
|
||||
return (
|
||||
<div
|
||||
style={{ scrollbarGutter: 'stable' }}
|
||||
className='sticky left-0 right-0 min-h-[85px] flex items-center gap-5 overflow-x-scroll overflow-y-hidden pl-spacing-7 pt-spacing-5'
|
||||
className='sticky left-0 right-0 top-0 z-10 min-h-[85px] flex items-center gap-5 overflow-x-scroll overflow-y-hidden bg-white pl-spacing-7 pt-spacing-5'
|
||||
>
|
||||
{!sidebarOpen && (
|
||||
<Button
|
||||
@@ -39,6 +40,9 @@ export default function CalendarHeader({ sidebarOpen, onSidebarToggle }: Calenda
|
||||
/>
|
||||
)}
|
||||
|
||||
<LargeLogo className='hidden! screenshot:flex!' />
|
||||
<Divider className='self-center hidden! screenshot:block!' size='2.5rem' orientation='vertical' />
|
||||
|
||||
<div className='min-w-[11.5rem] screenshot:transform-origin-left screenshot:scale-120'>
|
||||
<ScheduleTotalHoursAndCourses
|
||||
scheduleName={activeSchedule.name}
|
||||
@@ -62,7 +66,7 @@ export default function CalendarHeader({ sidebarOpen, onSidebarToggle }: Calenda
|
||||
className={clsx([
|
||||
styleResetClass,
|
||||
'mt-spacing-3',
|
||||
'min-w-max cursor-pointer origin-top-right rounded bg-white p-1 text-black shadow-lg transition border border-ut-offwhite/50 focus:outline-none',
|
||||
'min-w-max cursor-pointer origin-top-right rounded bg-white p-1 text-black shadow-lg transition border border-ut-offwhite/50 focus:outline-none z-20',
|
||||
'data-[closed]:(opacity-0 scale-95)',
|
||||
'data-[enter]:(ease-out-expo duration-150)',
|
||||
'data-[leave]:(ease-out duration-50)',
|
||||
@@ -94,6 +98,18 @@ export default function CalendarHeader({ sidebarOpen, onSidebarToggle }: Calenda
|
||||
Save as .cal
|
||||
</Button>
|
||||
</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>
|
||||
<Button color='ut-black' size='small' variant='minimal' icon={FileTxt}>
|
||||
Export Unique IDs
|
||||
|
||||
69
src/views/components/calendar/DiningAppPromo.tsx
Normal file
69
src/views/components/calendar/DiningAppPromo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -13,10 +13,10 @@ interface LinkItem {
|
||||
}
|
||||
|
||||
const links: LinkItem[] = [
|
||||
{
|
||||
text: "Spring '25 Course Schedule",
|
||||
url: 'https://utdirect.utexas.edu/apps/registrar/course_schedule/20252/',
|
||||
},
|
||||
// {
|
||||
// text: "Spring '25 Course Schedule",
|
||||
// url: 'https://utdirect.utexas.edu/apps/registrar/course_schedule/20252/',
|
||||
// },
|
||||
{
|
||||
text: 'Course Schedule Archives',
|
||||
url: 'https://registrar.utexas.edu/schedules/archive',
|
||||
|
||||
@@ -14,18 +14,14 @@ interface LinkItem {
|
||||
}
|
||||
|
||||
const links: LinkItem[] = [
|
||||
{
|
||||
text: "Spring '26 Course Schedule",
|
||||
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: "Summer '25 Course Schedule",
|
||||
url: 'https://utdirect.utexas.edu/apps/registrar/course_schedule/20256/',
|
||||
},
|
||||
{
|
||||
text: "Spring '25 Course Schedule",
|
||||
url: 'https://utdirect.utexas.edu/apps/registrar/course_schedule/20252/',
|
||||
},
|
||||
{
|
||||
text: 'Course Schedule Archives',
|
||||
url: 'https://registrar.utexas.edu/schedules/archive',
|
||||
@@ -34,10 +30,10 @@ const links: LinkItem[] = [
|
||||
text: 'My Degree Audit (IDA)',
|
||||
url: 'https://utdirect.utexas.edu/apps/degree/audits/',
|
||||
},
|
||||
// {
|
||||
// text: "'24-'25 Academic Calendar",
|
||||
// url: 'https://registrar.utexas.edu/calendars/24-25',
|
||||
// },
|
||||
{
|
||||
text: "'25-'26 Academic Calendar",
|
||||
url: 'https://registrar.utexas.edu/calendars/25-26',
|
||||
},
|
||||
{
|
||||
text: 'Registration Info Sheet (RIS)',
|
||||
url: 'https://utdirect.utexas.edu/registrar/ris.WBX',
|
||||
|
||||
200
src/views/components/calendar/academic-calendars.ts
Normal file
200
src/views/components/calendar/academic-calendars.ts
Normal 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>>;
|
||||
@@ -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 <-- Serialize, Deserialize
|
||||
*/
|
||||
const serde = <T>(data: T) => JSON.parse(JSON.stringify(data)) as Serialized<T>;
|
||||
|
||||
describe('formatToHHMMSS', () => {
|
||||
it('should format minutes to HHMMSS format', () => {
|
||||
@@ -24,3 +58,624 @@ describe('formatToHHMMSS', () => {
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextDayInclusive', () => {
|
||||
it('should return the same date if the given day is the same as the target day', () => {
|
||||
const date = parseISO('2024-01-01', { in: TZ }); // Monday
|
||||
const day = 1; // Monday
|
||||
const result = nextDayInclusive(date, day);
|
||||
expect(formatDate(result, ISO_DATE_FORMAT)).toBe('2024-01-01');
|
||||
});
|
||||
|
||||
it('should return the next day if the given day is not the same as the target day', () => {
|
||||
const date = parseISO('2024-07-18', { in: TZ }); // Thursday
|
||||
const day = 2; // Tuesday
|
||||
const result = nextDayInclusive(date, day);
|
||||
expect(formatDate(result, ISO_DATE_FORMAT)).toBe('2024-07-23');
|
||||
});
|
||||
|
||||
it('should wrap around years', () => {
|
||||
const date = parseISO('2025-12-28', { in: TZ }); // Sunday
|
||||
const day = 5; // Friday
|
||||
const result = nextDayInclusive(date, day);
|
||||
expect(formatDate(result, ISO_DATE_FORMAT)).toBe('2026-01-02');
|
||||
});
|
||||
|
||||
it('should handle leap day', () => {
|
||||
const date = parseISO('2024-02-27', { in: TZ }); // Tuesday
|
||||
const day = 4; // Thursday
|
||||
const result = nextDayInclusive(date, day);
|
||||
expect(formatDate(result, ISO_DATE_FORMAT)).toBe('2024-02-29');
|
||||
});
|
||||
|
||||
it('should handle an entire week of inputs', () => {
|
||||
const date = parseISO('2024-08-20', { in: TZ }); // Tuesday
|
||||
const days = [0, 1, 2, 3, 4, 5, 6] as const;
|
||||
const results = days.map(day => nextDayInclusive(date, day));
|
||||
const resultsFormatted = results.map(result => formatDate(result, ISO_DATE_FORMAT));
|
||||
const expectedResults = [
|
||||
'2024-08-25',
|
||||
'2024-08-26',
|
||||
'2024-08-20', // Same date
|
||||
'2024-08-21',
|
||||
'2024-08-22',
|
||||
'2024-08-23',
|
||||
'2024-08-24',
|
||||
];
|
||||
|
||||
for (let i = 0; i < days.length; i++) {
|
||||
expect(resultsFormatted[i]).toBe(expectedResults[i]);
|
||||
}
|
||||
});
|
||||
|
||||
it('should maintain hours/minutes/seconds', () => {
|
||||
const date = parseISO('20250115T143021', { in: TZ }); // Wednesday
|
||||
const days = [0, 1, 2, 3, 4, 5, 6] as const;
|
||||
const results = days.map(day => nextDayInclusive(date, day));
|
||||
const resultsFormatted = results.map(result => formatDate(result, ISO_BASIC_DATETIME_FORMAT));
|
||||
const expectedResults = [
|
||||
'20250119T143021',
|
||||
'20250120T143021',
|
||||
'20250121T143021',
|
||||
'20250115T143021',
|
||||
'20250116T143021',
|
||||
'20250117T143021',
|
||||
'20250118T143021',
|
||||
];
|
||||
|
||||
for (let i = 0; i < days.length; i++) {
|
||||
expect(resultsFormatted[i]).toBe(expectedResults[i]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('allDatesInRanges', () => {
|
||||
it('should handle empty array', () => {
|
||||
const dateRanges = [] satisfies string[];
|
||||
const result = allDatesInRanges(dateRanges);
|
||||
const expected = [] satisfies Date[];
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle a single date', () => {
|
||||
const dateRanges = ['2025-03-14'] satisfies (string | [string, string])[];
|
||||
const result = allDatesInRanges(dateRanges);
|
||||
const expected = ['2025-03-14'].map(dateStr => parseISO(dateStr, { in: TZ })) satisfies Date[];
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle a single date', () => {
|
||||
const dateRanges = ['2025-03-14'] satisfies string[];
|
||||
const result = allDatesInRanges(dateRanges);
|
||||
const expected = ['2025-03-14'].map(dateStr => parseISO(dateStr, { in: TZ })) satisfies Date[];
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle a single date range', () => {
|
||||
const dateRanges = [['2025-03-14', '2025-03-19']] satisfies (string | [string, string])[];
|
||||
const result = allDatesInRanges(dateRanges);
|
||||
const expected = ['2025-03-14', '2025-03-15', '2025-03-16', '2025-03-17', '2025-03-18', '2025-03-19'].map(
|
||||
dateStr => parseISO(dateStr, { in: TZ })
|
||||
) satisfies Date[];
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle multiple dates/date ranges', () => {
|
||||
const dateRanges = [
|
||||
'2025-02-14',
|
||||
['2025-03-14', '2025-03-19'],
|
||||
'2026-12-01',
|
||||
['2026-12-03', '2026-12-05'],
|
||||
] satisfies (string | [string, string])[];
|
||||
const result = allDatesInRanges(dateRanges);
|
||||
const expected = [
|
||||
'2025-02-14', // '2025-02-14'
|
||||
'2025-03-14', // ['2025-03-14', '2025-03-19']
|
||||
'2025-03-15',
|
||||
'2025-03-16',
|
||||
'2025-03-17',
|
||||
'2025-03-18',
|
||||
'2025-03-19',
|
||||
'2026-12-01', // '2026-12-01'
|
||||
'2026-12-03', // ['2026-12-03', '2026-12-05'
|
||||
'2026-12-04',
|
||||
'2026-12-05',
|
||||
].map(dateStr => parseISO(dateStr, { in: TZ })) satisfies Date[];
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle month-/year-spanning ranges', () => {
|
||||
const dateRanges = [
|
||||
['2023-02-27', '2023-03-02'],
|
||||
['2023-12-27', '2024-01-03'],
|
||||
] satisfies (string | [string, string])[];
|
||||
const result = allDatesInRanges(dateRanges);
|
||||
const expected = [
|
||||
'2023-02-27', // ['2023-02-27', '2023-03-2']
|
||||
'2023-02-28',
|
||||
'2023-03-01',
|
||||
'2023-03-02',
|
||||
'2023-12-27', // ['2023-12-27', '2024-01-3']
|
||||
'2023-12-28',
|
||||
'2023-12-29',
|
||||
'2023-12-30',
|
||||
'2023-12-31',
|
||||
'2024-01-01',
|
||||
'2024-01-02',
|
||||
'2024-01-03',
|
||||
].map(dateStr => parseISO(dateStr, { in: TZ })) satisfies Date[];
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle leap years', () => {
|
||||
const dateRanges = [
|
||||
['2023-02-27', '2023-03-02'],
|
||||
['2024-02-27', '2024-03-02'],
|
||||
['2025-02-27', '2025-03-02'],
|
||||
] satisfies (string | [string, string])[];
|
||||
const result = allDatesInRanges(dateRanges);
|
||||
const expected = [
|
||||
'2023-02-27', // ['2023-02-27', '2023-03-2']
|
||||
'2023-02-28',
|
||||
'2023-03-01',
|
||||
'2023-03-02',
|
||||
'2024-02-27', // ['2024-02-27', '2024-03-2']
|
||||
'2024-02-28',
|
||||
'2024-02-29',
|
||||
'2024-03-01',
|
||||
'2024-03-02',
|
||||
'2025-02-27', // ['2025-02-27', '2025-03-2']
|
||||
'2025-02-28',
|
||||
'2025-03-01',
|
||||
'2025-03-02',
|
||||
].map(dateStr => parseISO(dateStr, { in: TZ })) satisfies Date[];
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('meetingToIcsString', () => {
|
||||
it('should handle a one-day meeting with one instructor', () => {
|
||||
const course = serde(multiMeetingMultiInstructorCourse);
|
||||
course.instructors = course.instructors.slice(0, 1);
|
||||
const meeting = course.schedule.meetings[1]!;
|
||||
const result = meetingToIcsString(course, meeting);
|
||||
const expected = (
|
||||
`BEGIN:VEVENT
|
||||
DTSTART;TZID=America/Chicago:20250829T130000
|
||||
DTEND;TZID=America/Chicago:20250829T160000
|
||||
RRULE:FREQ=WEEKLY;BYDAY=FR;UNTIL=20251209T060000Z
|
||||
EXDATE;TZID=America/Chicago:20251128T130000
|
||||
` +
|
||||
// Only skips one Thanksgiving break day
|
||||
`SUMMARY:J 395 – 44-REPORTING TEXAS
|
||||
LOCATION:DMC 3.208
|
||||
DESCRIPTION:Unique number: 10335\\nTaught by John Schwartz
|
||||
END:VEVENT`
|
||||
).replaceAll(/^\s+/gm, '');
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle unique numbers below 5 digits', () => {
|
||||
const course = serde(multiMeetingMultiInstructorCourse);
|
||||
course.instructors = course.instructors.slice(0, 1);
|
||||
course.uniqueId = 4269;
|
||||
const meeting = course.schedule.meetings[1]!;
|
||||
const result = meetingToIcsString(course, meeting);
|
||||
const expected = `BEGIN:VEVENT
|
||||
DTSTART;TZID=America/Chicago:20250829T130000
|
||||
DTEND;TZID=America/Chicago:20250829T160000
|
||||
RRULE:FREQ=WEEKLY;BYDAY=FR;UNTIL=20251209T060000Z
|
||||
EXDATE;TZID=America/Chicago:20251128T130000
|
||||
SUMMARY:J 395 – 44-REPORTING TEXAS
|
||||
LOCATION:DMC 3.208
|
||||
DESCRIPTION:Unique number: 04269\\nTaught by John Schwartz
|
||||
END:VEVENT`.replaceAll(/^\s+/gm, '');
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle a one-day meeting with multiple instructors', () => {
|
||||
const course = serde(multiMeetingMultiInstructorCourse);
|
||||
const meeting = course.schedule.meetings[1]!;
|
||||
const result = meetingToIcsString(course, meeting);
|
||||
const expected = `BEGIN:VEVENT
|
||||
DTSTART;TZID=America/Chicago:20250829T130000
|
||||
DTEND;TZID=America/Chicago:20250829T160000
|
||||
RRULE:FREQ=WEEKLY;BYDAY=FR;UNTIL=20251209T060000Z
|
||||
EXDATE;TZID=America/Chicago:20251128T130000
|
||||
SUMMARY:J 395 – 44-REPORTING TEXAS
|
||||
LOCATION:DMC 3.208
|
||||
DESCRIPTION:Unique number: 10335\\nTaught by John Schwartz and John Bridges
|
||||
END:VEVENT`.replaceAll(/^\s+/gm, '');
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it('should gracefully error on an out of range semester code', () => {
|
||||
const course = serde(multiMeetingMultiInstructorCourse);
|
||||
const meeting = course.schedule.meetings[0]!;
|
||||
vi.spyOn(console, 'error').mockReturnValue(undefined);
|
||||
course.semester = {
|
||||
season: 'Fall',
|
||||
year: 2010,
|
||||
code: '20109',
|
||||
};
|
||||
const result = meetingToIcsString(course, meeting);
|
||||
expect(result).toBeNull();
|
||||
expect(console.error).toBeCalledWith(
|
||||
`No academic calendar found for semester code: 20109; course uniqueId: ${course.uniqueId}`
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle a multi-day meeting with multiple instructors', () => {
|
||||
const course = serde(multiMeetingMultiInstructorCourse);
|
||||
const meeting = course.schedule.meetings[0]!;
|
||||
const result = meetingToIcsString(course, meeting);
|
||||
const expected = `BEGIN:VEVENT
|
||||
DTSTART;TZID=America/Chicago:20250826T093000
|
||||
DTEND;TZID=America/Chicago:20250826T110000
|
||||
RRULE:FREQ=WEEKLY;BYDAY=TU,TH;UNTIL=20251209T060000Z
|
||||
EXDATE;TZID=America/Chicago:20251125T093000,20251127T093000
|
||||
SUMMARY:J 395 – 44-REPORTING TEXAS
|
||||
LOCATION:CMA 6.146
|
||||
DESCRIPTION:Unique number: 10335\\nTaught by John Schwartz and John Bridges
|
||||
END:VEVENT`.replaceAll(/^\s+/gm, '');
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
describe('scheduleToIcsString', () => {
|
||||
it('should handle an empty schedule', () => {
|
||||
const schedule = serde(
|
||||
new UserSchedule({
|
||||
courses: [],
|
||||
hours: 0,
|
||||
id: 'fajowe',
|
||||
name: 'fajowe',
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
);
|
||||
const result = scheduleToIcsString(schedule);
|
||||
const expected = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
CALSCALE:GREGORIAN
|
||||
X-WR-CALNAME:My Schedule
|
||||
END:VCALENDAR`.replaceAll(/^\s+/gm, '');
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle a schedule with courses but no meetings', () => {
|
||||
const schedule = serde(
|
||||
new UserSchedule({
|
||||
courses: [
|
||||
new Course({
|
||||
...multiMeetingMultiInstructorCourse,
|
||||
schedule: {
|
||||
meetings: [],
|
||||
},
|
||||
}),
|
||||
],
|
||||
hours: 0,
|
||||
id: 'fajowe',
|
||||
name: 'fajowe',
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
);
|
||||
const result = scheduleToIcsString(schedule);
|
||||
const expected = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
CALSCALE:GREGORIAN
|
||||
X-WR-CALNAME:My Schedule
|
||||
END:VCALENDAR`.replaceAll(/^\s+/gm, '');
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle a schedule with courses but out-of-range semester', () => {
|
||||
vi.spyOn(console, 'error').mockReturnValue(undefined);
|
||||
const schedule = serde(
|
||||
new UserSchedule({
|
||||
courses: [
|
||||
new Course({
|
||||
...multiMeetingMultiInstructorCourse,
|
||||
semester: {
|
||||
season: 'Fall',
|
||||
year: 2010,
|
||||
code: '20109',
|
||||
},
|
||||
}),
|
||||
],
|
||||
hours: 0,
|
||||
id: 'fajowe',
|
||||
name: 'fajowe',
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
);
|
||||
const result = scheduleToIcsString(schedule);
|
||||
const expected = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
CALSCALE:GREGORIAN
|
||||
X-WR-CALNAME:My Schedule
|
||||
END:VCALENDAR`.replaceAll(/^\s+/gm, '');
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle a single course with multiple meetings', () => {
|
||||
const schedule = serde(multiMeetingMultiInstructorSchedule);
|
||||
const result = scheduleToIcsString(schedule);
|
||||
const expected = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
CALSCALE:GREGORIAN
|
||||
X-WR-CALNAME:My Schedule
|
||||
BEGIN:VEVENT
|
||||
DTSTART;TZID=America/Chicago:20250826T093000
|
||||
DTEND;TZID=America/Chicago:20250826T110000
|
||||
RRULE:FREQ=WEEKLY;BYDAY=TU,TH;UNTIL=20251209T060000Z
|
||||
EXDATE;TZID=America/Chicago:20251125T093000,20251127T093000
|
||||
SUMMARY:J 395 – 44-REPORTING TEXAS
|
||||
LOCATION:CMA 6.146
|
||||
DESCRIPTION:Unique number: 10335\\nTaught by John Schwartz and John Bridges
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART;TZID=America/Chicago:20250829T130000
|
||||
DTEND;TZID=America/Chicago:20250829T160000
|
||||
RRULE:FREQ=WEEKLY;BYDAY=FR;UNTIL=20251209T060000Z
|
||||
EXDATE;TZID=America/Chicago:20251128T130000
|
||||
SUMMARY:J 395 – 44-REPORTING TEXAS
|
||||
LOCATION:DMC 3.208
|
||||
DESCRIPTION:Unique number: 10335\\nTaught by John Schwartz and John Bridges
|
||||
END:VEVENT
|
||||
END:VCALENDAR`.replaceAll(/^\s+/gm, '');
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle a complex schedule', () => {
|
||||
const schedule = serde(multiMeetingMultiInstructorSchedule);
|
||||
schedule.courses.push(chatterjeeCS429Course);
|
||||
const result = scheduleToIcsString(schedule);
|
||||
const expected = (
|
||||
`BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
CALSCALE:GREGORIAN
|
||||
X-WR-CALNAME:My Schedule
|
||||
BEGIN:VEVENT
|
||||
DTSTART;TZID=America/Chicago:20250826T093000
|
||||
DTEND;TZID=America/Chicago:20250826T110000
|
||||
RRULE:FREQ=WEEKLY;BYDAY=TU,TH;UNTIL=20251209T060000Z
|
||||
EXDATE;TZID=America/Chicago:20251125T093000,20251127T093000
|
||||
SUMMARY:J 395 – 44-REPORTING TEXAS
|
||||
LOCATION:CMA 6.146
|
||||
DESCRIPTION:Unique number: 10335\\nTaught by John Schwartz and John Bridges
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART;TZID=America/Chicago:20250829T130000
|
||||
DTEND;TZID=America/Chicago:20250829T160000
|
||||
RRULE:FREQ=WEEKLY;BYDAY=FR;UNTIL=20251209T060000Z
|
||||
EXDATE;TZID=America/Chicago:20251128T130000
|
||||
SUMMARY:J 395 – 44-REPORTING TEXAS
|
||||
LOCATION:DMC 3.208
|
||||
DESCRIPTION:Unique number: 10335\\nTaught by John Schwartz and John Bridges
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART;TZID=America/Chicago:20250825T160000
|
||||
DTEND;TZID=America/Chicago:20250825T170000
|
||||
RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH;UNTIL=20251209T060000Z
|
||||
` +
|
||||
// Skips Labor Day and only relevant days of Thanksgiving
|
||||
`EXDATE;TZID=America/Chicago:20250901T160000,20251124T160000,20251125T160000,20251126T160000,20251127T160000
|
||||
SUMMARY:C S 429 – COMP ORGANIZATN AND ARCH
|
||||
LOCATION:UTC 3.102
|
||||
DESCRIPTION:Unique number: 54795\\nTaught by Siddhartha Chatterjee
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART;TZID=America/Chicago:20250829T090000
|
||||
DTEND;TZID=America/Chicago:20250829T110000
|
||||
RRULE:FREQ=WEEKLY;BYDAY=FR;UNTIL=20251209T060000Z
|
||||
EXDATE;TZID=America/Chicago:20251128T090000
|
||||
SUMMARY:C S 429 – COMP ORGANIZATN AND ARCH
|
||||
LOCATION:GSB 2.122
|
||||
DESCRIPTION:Unique number: 54795\\nTaught by Siddhartha Chatterjee
|
||||
END:VEVENT
|
||||
END:VCALENDAR`
|
||||
).replaceAll(/^\s+/gm, '');
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 type { Course } from '@shared/types/Course';
|
||||
import type { CourseMeeting } from '@shared/types/CourseMeeting';
|
||||
import Instructor from '@shared/types/Instructor';
|
||||
import type { UserSchedule } from '@shared/types/UserSchedule';
|
||||
import { downloadBlob } from '@shared/util/downloadBlob';
|
||||
import { englishStringifyList } from '@shared/util/string';
|
||||
import type { CalendarGridCourse } from '@views/hooks/useFlattenedCourseSchedule';
|
||||
import type { Serialized } from 'chrome-extension-toolkit';
|
||||
import type { DateArg, Day } from 'date-fns';
|
||||
import {
|
||||
addDays,
|
||||
eachDayOfInterval,
|
||||
format as formatDate,
|
||||
formatISO,
|
||||
getDay,
|
||||
nextDay,
|
||||
parseISO,
|
||||
set as setMultiple,
|
||||
} from 'date-fns';
|
||||
import { toBlob } from 'html-to-image';
|
||||
|
||||
import { academicCalendars } from './academic-calendars';
|
||||
|
||||
// Do all timezone calculations relative to UT's timezone
|
||||
const TIMEZONE_ID = 'America/Chicago';
|
||||
const TZ = tz(TIMEZONE_ID);
|
||||
|
||||
// Datetime format used by iCal, not directly supported by date-fns
|
||||
// (date-fns adds the timezone to the end, but iCal doesn't want it)
|
||||
const ISO_BASIC_DATETIME_FORMAT = "yyyyMMdd'T'HHmmss";
|
||||
|
||||
// iCal uses two-letter codes for days of the week
|
||||
export const CAL_MAP = {
|
||||
Sunday: 'SU',
|
||||
Monday: 'MO',
|
||||
@@ -14,6 +43,17 @@ export const CAL_MAP = {
|
||||
Saturday: 'SA',
|
||||
} as const satisfies Record<string, string>;
|
||||
|
||||
// Date objects' day field goes by index like this
|
||||
const DAY_NAME_TO_NUMBER = {
|
||||
Sunday: 0,
|
||||
Monday: 1,
|
||||
Tuesday: 2,
|
||||
Wednesday: 3,
|
||||
Thursday: 4,
|
||||
Friday: 5,
|
||||
Saturday: 6,
|
||||
} as const satisfies Record<string, number>;
|
||||
|
||||
/**
|
||||
* Retrieves the schedule from the UserScheduleStore based on the active index.
|
||||
* @returns A promise that resolves to the retrieved schedule.
|
||||
@@ -38,85 +78,244 @@ export const formatToHHMMSS = (minutes: number) => {
|
||||
return `${hours}${mins}00`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a date in the format YYYYMMDD'T'HHmmss, which is the format used by iCal.
|
||||
*
|
||||
* @param date - The date to format.
|
||||
* @returns The formatted date string.
|
||||
*/
|
||||
const iCalDateFormat = <DateType extends Date>(date: DateArg<DateType>) =>
|
||||
formatDate(date, ISO_BASIC_DATETIME_FORMAT, { in: TZ });
|
||||
|
||||
/**
|
||||
* Returns the next day of the given date, inclusive of the given day.
|
||||
*
|
||||
* If the given date is the given day, the same date is returned.
|
||||
*
|
||||
* For example, a Monday targeting a Wednesday will return the next Wednesday,
|
||||
* but if it was targeting a Monday it would return the same date.
|
||||
*
|
||||
* @param date - The date to increment.
|
||||
* @param day - The day to increment to. (0 = Sunday, 1 = Monday, etc.)
|
||||
* @returns The next day of the given date, inclusive of the given day.
|
||||
*/
|
||||
export const nextDayInclusive = (date: Date, day: Day): TZDate => {
|
||||
if (getDay(date, { in: TZ }) === day) {
|
||||
return new TZDate(date, TIMEZONE_ID);
|
||||
}
|
||||
|
||||
return nextDay(date, day, { in: TZ });
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns an array of all the dates (as Date objects) in the given date ranges.
|
||||
*
|
||||
* @param dateRanges - An array of date ranges.
|
||||
* Each date range can be a string (in which case it is interpreted as a single date)
|
||||
* or an array of two strings (in which case it is interpreted as a date range, inclusive).
|
||||
* @returns An array of all the dates (as Date objects) in the given date ranges.
|
||||
*
|
||||
* @example
|
||||
* allDatesInRanges(['2025-01-01', ['2025-03-14', '2025-03-16']]) // ['2025-01-01', '2025-03-14', '2025-03-15', '2025-03-16'] (as Date objects)
|
||||
*
|
||||
* @remarks Does not remove duplicate dates.
|
||||
*/
|
||||
export const allDatesInRanges = (dateRanges: readonly (string | [string, string])[]): Date[] =>
|
||||
dateRanges.flatMap(breakDate => {
|
||||
if (Array.isArray(breakDate)) {
|
||||
return eachDayOfInterval({
|
||||
start: parseISO(breakDate[0], { in: TZ }),
|
||||
end: parseISO(breakDate[1], { in: TZ }),
|
||||
});
|
||||
}
|
||||
|
||||
return parseISO(breakDate, { in: TZ });
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a VEVENT string for a meeting of a course.
|
||||
*
|
||||
* @param course - The course object
|
||||
* @param meeting - The meeting object
|
||||
* @returns A string representation of the meeting in the iCalendar format (ICS)
|
||||
*/
|
||||
export const meetingToIcsString = (course: Serialized<Course>, meeting: Serialized<CourseMeeting>): string | null => {
|
||||
const { startTime, endTime, days, location } = meeting;
|
||||
if (!course.semester.code) {
|
||||
console.error(`No semester found for course uniqueId: ${course.uniqueId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (days.length === 0) {
|
||||
console.error(`No days found for course uniqueId: ${course.uniqueId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(academicCalendars, course.semester.code)) {
|
||||
console.error(
|
||||
`No academic calendar found for semester code: ${course.semester.code}; course uniqueId: ${course.uniqueId}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const academicCalendar = academicCalendars[course.semester.code as keyof typeof academicCalendars];
|
||||
|
||||
const startDate = nextDayInclusive(
|
||||
parseISO(academicCalendar.firstClassDate, { in: TZ }),
|
||||
DAY_NAME_TO_NUMBER[days[0]!]
|
||||
);
|
||||
|
||||
const startTimeHours = Math.floor(startTime / 60);
|
||||
const startTimeMinutes = startTime % 60;
|
||||
const startTimeDate = setMultiple(startDate, { hours: startTimeHours, minutes: startTimeMinutes }, { in: TZ });
|
||||
|
||||
const endTimeHours = Math.floor(endTime / 60);
|
||||
const endTimeMinutes = endTime % 60;
|
||||
const endTimeDate = setMultiple(startDate, { hours: endTimeHours, minutes: endTimeMinutes }, { in: TZ });
|
||||
|
||||
const untilDate = addDays(parseISO(academicCalendar.lastClassDate, { in: TZ }), 1);
|
||||
|
||||
const daysNumSet = new Set(days.map(d => DAY_NAME_TO_NUMBER[d]));
|
||||
const excludedDates = allDatesInRanges(academicCalendar.breakDates)
|
||||
// Don't need to exclude Tues/Thurs if it's a MWF class, etc.
|
||||
.filter(date => daysNumSet.has(getDay(date, { in: TZ }) as Day))
|
||||
.map(date => setMultiple(date, { hours: startTimeHours, minutes: startTimeMinutes }, { in: TZ }));
|
||||
|
||||
const startDateFormatted = iCalDateFormat(startTimeDate);
|
||||
const endDateFormatted = iCalDateFormat(endTimeDate);
|
||||
// Convert days to ICS compatible format, e.g. MO,WE,FR
|
||||
const icsDays = days.map(day => CAL_MAP[day]).join(',');
|
||||
|
||||
// per spec, UNTIL must be in UTC
|
||||
const untilDateFormatted = formatISO(untilDate, { format: 'basic', in: tz('utc') });
|
||||
const excludedDatesFormatted = excludedDates.map(date => iCalDateFormat(date));
|
||||
|
||||
const uniqueNumberFormatted = course.uniqueId.toString().padStart(5, '0');
|
||||
|
||||
// The list part of "Taught by Michael Scott and Siddhartha Chatterjee Beasley"
|
||||
const instructorsFormatted = englishStringifyList(
|
||||
course.instructors
|
||||
.map(instructor => Instructor.prototype.toString.call(instructor, { format: 'first_last' }))
|
||||
.filter(name => name !== '')
|
||||
);
|
||||
|
||||
// Construct event string
|
||||
let icsString = 'BEGIN:VEVENT\n';
|
||||
icsString += `DTSTART;TZID=${TIMEZONE_ID}:${startDateFormatted}\n`;
|
||||
icsString += `DTEND;TZID=${TIMEZONE_ID}:${endDateFormatted}\n`;
|
||||
icsString += `RRULE:FREQ=WEEKLY;BYDAY=${icsDays};UNTIL=${untilDateFormatted}\n`;
|
||||
icsString += `EXDATE;TZID=${TIMEZONE_ID}:${excludedDatesFormatted.join(',')}\n`;
|
||||
icsString += `SUMMARY:${course.department} ${course.number} \u2013 ${course.courseName}\n`;
|
||||
|
||||
if (location?.building || location?.building) {
|
||||
const locationFormatted = `${location?.building ?? ''} ${location?.room ?? ''}`.trim();
|
||||
icsString += `LOCATION:${locationFormatted}\n`;
|
||||
}
|
||||
|
||||
icsString += `DESCRIPTION:Unique number: ${uniqueNumberFormatted}`;
|
||||
if (instructorsFormatted) {
|
||||
// Newlines need to be double-escaped
|
||||
icsString += `\\nTaught by ${instructorsFormatted}`;
|
||||
}
|
||||
icsString += '\n';
|
||||
|
||||
icsString += 'END:VEVENT';
|
||||
|
||||
return icsString;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a VCALENDAR string for a schedule of a user.
|
||||
* @param schedule - The schedule object
|
||||
* @returns A string representation of the schedule in the iCalendar format (ICS)
|
||||
*/
|
||||
export const scheduleToIcsString = (schedule: Serialized<UserSchedule>) => {
|
||||
let icsString = 'BEGIN:VCALENDAR\nVERSION:2.0\nCALSCALE:GREGORIAN\nX-WR-CALNAME:My Schedule\n';
|
||||
|
||||
const vevents = schedule.courses
|
||||
.flatMap(course => course.schedule.meetings.map(meeting => meetingToIcsString(course, meeting)))
|
||||
.filter(event => event !== null)
|
||||
.join('\n');
|
||||
|
||||
if (vevents.length > 0) {
|
||||
icsString += `${vevents}\n`;
|
||||
}
|
||||
|
||||
icsString += 'END:VCALENDAR';
|
||||
|
||||
return icsString;
|
||||
};
|
||||
|
||||
/**
|
||||
* Saves the current schedule as a calendar file in the iCalendar format (ICS).
|
||||
* Fetches the current active schedule and converts it into an ICS string.
|
||||
* Downloads the ICS file to the user's device.
|
||||
*/
|
||||
export const saveAsCal = async () => {
|
||||
const schedule = await getSchedule(); // Assumes this fetches the current active schedule
|
||||
|
||||
let icsString = 'BEGIN:VCALENDAR\nVERSION:2.0\nCALSCALE:GREGORIAN\nX-WR-CALNAME:My Schedule\n';
|
||||
const schedule = await getSchedule();
|
||||
|
||||
if (!schedule) {
|
||||
throw new Error('No schedule found');
|
||||
}
|
||||
|
||||
schedule.courses.forEach(course => {
|
||||
course.schedule.meetings.forEach(meeting => {
|
||||
const { startTime, endTime, days, location } = meeting;
|
||||
|
||||
// Format start and end times to HHMMSS
|
||||
const formattedStartTime = formatToHHMMSS(startTime);
|
||||
const formattedEndTime = formatToHHMMSS(endTime);
|
||||
|
||||
// Map days to ICS compatible format
|
||||
console.log(days);
|
||||
const icsDays = days.map(day => CAL_MAP[day]).join(',');
|
||||
console.log(icsDays);
|
||||
|
||||
// Assuming course has date started and ended, adapt as necessary
|
||||
// const year = new Date().getFullYear(); // Example year, adapt accordingly
|
||||
// Example event date, adapt startDate according to your needs
|
||||
const startDate = `20240101T${formattedStartTime}`;
|
||||
const endDate = `20240101T${formattedEndTime}`;
|
||||
|
||||
icsString += `BEGIN:VEVENT\n`;
|
||||
icsString += `DTSTART:${startDate}\n`;
|
||||
icsString += `DTEND:${endDate}\n`;
|
||||
icsString += `RRULE:FREQ=WEEKLY;BYDAY=${icsDays}\n`;
|
||||
icsString += `SUMMARY:${course.fullName}\n`;
|
||||
icsString += `LOCATION:${location?.building ?? ''} ${location?.room ?? ''}\n`;
|
||||
icsString += `END:VEVENT\n`;
|
||||
});
|
||||
});
|
||||
|
||||
icsString += 'END:VCALENDAR';
|
||||
const icsString = scheduleToIcsString(schedule);
|
||||
|
||||
downloadBlob(icsString, 'CALENDAR', 'schedule.ics');
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param calendarRef - The reference to the calendar component.
|
||||
*/
|
||||
export const saveCalAsPng = () => {
|
||||
const WIDTH_PX = 1165;
|
||||
const HEIGHT_PX = 754;
|
||||
const SCALE = 2;
|
||||
|
||||
const rootNode = document.createElement('div');
|
||||
rootNode.style.backgroundColor = 'white';
|
||||
rootNode.style.position = 'fixed';
|
||||
rootNode.style.zIndex = '1000';
|
||||
rootNode.style.top = '-10000px';
|
||||
rootNode.style.left = '-10000px';
|
||||
rootNode.style.width = '1165px';
|
||||
rootNode.style.height = '754px';
|
||||
rootNode.style.width = `${WIDTH_PX}px`;
|
||||
rootNode.style.height = `${HEIGHT_PX}px`;
|
||||
document.body.appendChild(rootNode);
|
||||
|
||||
const clonedNode = document.querySelector('#root')!.cloneNode(true) as HTMLDivElement;
|
||||
clonedNode.style.backgroundColor = 'white';
|
||||
(clonedNode.firstChild as HTMLDivElement).classList.add('screenshot-in-progress');
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
requestAnimationFrame(async () => {
|
||||
rootNode.appendChild(clonedNode);
|
||||
const calendarTarget = clonedNode.querySelector('.screenshot\\:calendar-target') as HTMLDivElement;
|
||||
calendarTarget.style.width = `${WIDTH_PX}px`;
|
||||
calendarTarget.style.height = `${HEIGHT_PX}px`;
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
rootNode.appendChild(clonedNode);
|
||||
requestAnimationFrame(async () => {
|
||||
try {
|
||||
const screenshotBlob = await toBlob(clonedNode, {
|
||||
cacheBust: true,
|
||||
canvasWidth: 1165 * 2,
|
||||
canvasHeight: 754 * 2,
|
||||
canvasWidth: WIDTH_PX * SCALE,
|
||||
canvasHeight: HEIGHT_PX * SCALE,
|
||||
skipAutoScale: true,
|
||||
pixelRatio: 2,
|
||||
pixelRatio: SCALE,
|
||||
});
|
||||
|
||||
if (!screenshotBlob) {
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
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.
|
||||
|
||||
@@ -46,7 +46,7 @@ export function usePrompt(): (info: PromptInfo, options?: DialogOptions) => void
|
||||
{info.description}
|
||||
</Text>
|
||||
),
|
||||
className: 'max-w-[400px] flex flex-col gap-2.5 p-6.25',
|
||||
className: 'max-w-[415px] flex flex-col gap-2.5 p-6.25 border border-ut-offwhite/50',
|
||||
},
|
||||
options
|
||||
);
|
||||
|
||||
@@ -15,6 +15,11 @@
|
||||
@apply font-sans;
|
||||
color: #303030;
|
||||
|
||||
// fix font-family on injected pages
|
||||
* {
|
||||
@apply font-sans;
|
||||
}
|
||||
|
||||
[data-rfd-drag-handle-context-id=':r1:'] {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// import '@unocss/reset/tailwind-compat.css';
|
||||
import 'uno.css';
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import clsx from 'clsx';
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
@@ -8,6 +9,8 @@ import styles from './ExtensionRoot.module.scss';
|
||||
|
||||
export const styleResetClass = styles.extensionRoot;
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
/**
|
||||
* A wrapper component for the extension elements that adds some basic styling to them
|
||||
*/
|
||||
@@ -16,7 +19,9 @@ export default function ExtensionRoot(props: React.HTMLProps<HTMLDivElement>): J
|
||||
|
||||
return (
|
||||
<React.StrictMode>
|
||||
<div className={clsx(styleResetClass, 'h-full', className)} {...others} />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<div className={clsx(styleResetClass, 'h-full', className)} {...others} />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function FileUpload({
|
||||
} satisfies React.CSSProperties
|
||||
}
|
||||
className={clsx(
|
||||
'btn has-enabled:active:scale-96',
|
||||
'btn',
|
||||
{
|
||||
'text-white! bg-opacity-100 hover:enabled:shadow-md active:enabled:shadow-sm shadow-black/20':
|
||||
variant === 'filled',
|
||||
|
||||
@@ -102,7 +102,7 @@ export function useMigrationDialog() {
|
||||
{
|
||||
title: 'This extension has updated!',
|
||||
description:
|
||||
"You may have already began planning your Spring '25 schedule. Click the button below to transfer your saved schedules into a new schedule. (You may be required to login to the UT Registrar)",
|
||||
"You may have already began planning your Fall '25 schedule. Click the button below to transfer your saved schedules into a new schedule. (You may be required to login to the UT Registrar)",
|
||||
|
||||
buttons: close => <MigrationButtons close={close} />,
|
||||
},
|
||||
|
||||
@@ -19,13 +19,17 @@ export default function ScheduleDropdown({ defaultOpen, children }: ScheduleDrop
|
||||
const [activeSchedule] = useSchedules();
|
||||
|
||||
return (
|
||||
<div className='border border-ut-offwhite/50 rounded bg-white'>
|
||||
<div className='max-h-[200px] flex flex-col border border-ut-offwhite/50 rounded bg-white'>
|
||||
<Disclosure defaultOpen={defaultOpen}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<DisclosureButton className='w-full flex items-center border-none bg-transparent px-3.5 py-2.5 text-left'>
|
||||
<div className='flex-1'>
|
||||
<Text as='div' variant='h3' className='w-100% text-ut-burntorange normal-case!'>
|
||||
<div className='min-w-0 flex-1 overflow-hidden'>
|
||||
<Text
|
||||
as='div'
|
||||
variant='h3'
|
||||
className='w-full truncate whitespace-nowrap text-ut-burntorange normal-case!'
|
||||
>
|
||||
{activeSchedule ? activeSchedule.name : 'Schedule'}
|
||||
</Text>
|
||||
<div className='flex gap-2.5 text-theme-black leading-[75%]!'>
|
||||
@@ -50,17 +54,17 @@ export default function ScheduleDropdown({ defaultOpen, children }: ScheduleDrop
|
||||
|
||||
<Transition
|
||||
as='div'
|
||||
className='overflow-hidden'
|
||||
className='flex flex-1 flex-col overflow-y-hidden'
|
||||
enter='transition-[max-height,opacity,padding] duration-300 ease-in-out-expo'
|
||||
enterFrom='max-h-0 opacity-0 p-0.5'
|
||||
enterTo='max-h-[440px] opacity-100 p-0'
|
||||
enterTo='max-h-[200px] opacity-100 p-0'
|
||||
leave='transition-[max-height,opacity,padding] duration-300 ease-in-out-expo'
|
||||
leaveFrom='max-h-[440px] opacity-100 p-0'
|
||||
leaveFrom='max-h-[200px] opacity-100 p-0'
|
||||
leaveTo='max-h-0 opacity-0 p-0.5'
|
||||
>
|
||||
<div className='px-3.5 pb-2.5 pt-2'>
|
||||
<DisclosurePanel>{children}</DisclosurePanel>
|
||||
</div>
|
||||
<DisclosurePanel className='mx-1.75 mb-2.5 mt-2 flex flex-1 flex-col overflow-y-auto'>
|
||||
<div className='mx-1.75'>{children}</div>
|
||||
</DisclosurePanel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -109,12 +109,14 @@ export default function ScheduleListItem({ schedule, onClick }: ScheduleListItem
|
||||
|
||||
const handleDelete = () => {
|
||||
showDialog({
|
||||
title: 'Are you sure?',
|
||||
title: 'Delete schedule?',
|
||||
description: (
|
||||
<>
|
||||
<Text>Deleting</Text>
|
||||
<Text className='text-ut-burntorange'> {schedule.name} </Text>
|
||||
<Text>is permanent and will remove all added courses from that schedule.</Text>
|
||||
<Text>Deleting </Text>
|
||||
<Text className='text-ut-burntorange'>{schedule.name}</Text>
|
||||
<Text> is permanent and will remove all added courses from </Text>
|
||||
<Text className='text-ut-burntorange'>{schedule.name}</Text>
|
||||
<Text>.</Text>
|
||||
</>
|
||||
),
|
||||
// eslint-disable-next-line react/no-unstable-nested-components
|
||||
@@ -126,12 +128,13 @@ export default function ScheduleListItem({ schedule, onClick }: ScheduleListItem
|
||||
<Button
|
||||
variant='filled'
|
||||
color='theme-red'
|
||||
icon={Trash}
|
||||
onClick={() => {
|
||||
close();
|
||||
deleteSchedule(schedule.id);
|
||||
}}
|
||||
>
|
||||
Delete Permanently
|
||||
Delete permanently
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
139
src/views/components/common/WhatsNewPopup.tsx
Normal file
139
src/views/components/common/WhatsNewPopup.tsx
Normal 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;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { addCourseByURL } from '@pages/background/lib/addCourseByURL';
|
||||
import { background } from '@shared/messages';
|
||||
import { validateLoginStatus } from '@shared/util/checkLoginStatus';
|
||||
import { Button } from '@views/components/common/Button';
|
||||
import ExtensionRoot from '@views/components/common/ExtensionRoot/ExtensionRoot';
|
||||
import useSchedules from '@views/hooks/useSchedules';
|
||||
@@ -43,6 +42,8 @@ export default function InjectedButton(): JSX.Element | null {
|
||||
await addCourseByURL(activeSchedule, a);
|
||||
}
|
||||
} else {
|
||||
// We'll allow the alert for this WIP feature
|
||||
// eslint-disable-next-line no-alert
|
||||
window.alert('Logged into UT Registrar.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -140,7 +140,7 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
|
||||
tickWidth: 1,
|
||||
tickLength: 10,
|
||||
tickColor: '#9CADB7',
|
||||
crosshair: { color: extendedColors.theme.offwhite },
|
||||
crosshair: { color: `${extendedColors.theme.offwhite}50` },
|
||||
lineColor: '#9CADB7',
|
||||
},
|
||||
yAxis: {
|
||||
@@ -215,7 +215,7 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
|
||||
options={{
|
||||
...chartOptions,
|
||||
title: {
|
||||
text: `There is currently no grade distribution data for ${course.department} ${course.number}`,
|
||||
text: `There is currently no grade distribution data for ${course.department} ${course.getNumberWithoutTerm()}`,
|
||||
},
|
||||
tooltip: { enabled: false },
|
||||
}}
|
||||
@@ -228,7 +228,7 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
|
||||
<Text variant='small' className='text-ut-black'>
|
||||
Grade Distribution for{' '}
|
||||
<Text variant='small' className='font-extrabold!' as='strong'>
|
||||
{course.department} {course.number}
|
||||
{course.department} {course.getNumberWithoutTerm()}
|
||||
</Text>
|
||||
</Text>
|
||||
<select
|
||||
@@ -267,7 +267,8 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
|
||||
<div className='mt-3 flex flex-wrap content-center items-center self-stretch justify-center gap-3 text-center'>
|
||||
<Text variant='small' className='text-theme-red'>
|
||||
We couldn't find {semester !== 'Aggregate' && ` ${semester}`} grades for this
|
||||
instructor, so here are the grades for all {course.department} {course.number} sections.
|
||||
instructor, so here are the grades for all {course.department}{' '}
|
||||
{course.getNumberWithoutTerm()} sections.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import createSchedule from '@pages/background/lib/createSchedule';
|
||||
import switchSchedule from '@pages/background/lib/switchSchedule';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
CalendarDots,
|
||||
@@ -14,8 +16,10 @@ import { background } from '@shared/messages';
|
||||
import type { Course } from '@shared/types/Course';
|
||||
import type Instructor from '@shared/types/Instructor';
|
||||
import type { UserSchedule } from '@shared/types/UserSchedule';
|
||||
import { englishStringifyList } from '@shared/util/string';
|
||||
import { Button } from '@views/components/common/Button';
|
||||
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 Link from '@views/components/common/Link';
|
||||
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 lastCopyTime = useRef<number>(0);
|
||||
|
||||
const showDialog = usePrompt();
|
||||
const getInstructorFullName = (instructor: Instructor) => instructor.toString({ format: 'first_last' });
|
||||
|
||||
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 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 (!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'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 {
|
||||
removeCourse({ course, scheduleId: activeSchedule.id });
|
||||
}
|
||||
|
||||
89
src/views/components/injected/DaysCheckbox.tsx
Normal file
89
src/views/components/injected/DaysCheckbox.tsx
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,49 @@
|
||||
import Link from '@views/components/common/Link';
|
||||
import Text from '@views/components/common/Text/Text';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import styles from './RecruitmentBanner.module.scss';
|
||||
|
||||
const DISCORD_URL = 'https://discord.gg/7pQDBGdmb7';
|
||||
const GITHUB_URL = 'https://github.com/Longhorn-Developers/UT-Registration-Plus';
|
||||
const DESIGNER_APPLICATION_URL =
|
||||
'https://docs.google.com/forms/d/e/1FAIpQLSdX1Bb37tW6s1bkdIW3GJoTGcM_Uc-2DzFOFMXxGdn1jZ3K1A/viewform';
|
||||
|
||||
const RECRUIT_FROM_DEPARTMENTS = ['C S', 'ECE', 'MIS', 'CSE', 'EE', 'ITD', 'DES'];
|
||||
// The lists below _must_ be mutually exclusive
|
||||
const DEVELOPER_RECRUIT_FROM_DEPARTMENTS = new Set(['C S', 'ECE', 'MIS', 'CSE', 'EE', 'ITD']);
|
||||
const DESIGNER_RECRUIT_FROM_DEPARTMENTS = new Set(['I', 'DES', 'AET']);
|
||||
|
||||
type RecruitmentType = 'DEVELOPER' | 'DESIGNER' | 'NONE';
|
||||
|
||||
const DeveloperRecruitmentBanner = () => (
|
||||
<div className={styles.container}>
|
||||
<Text className='text-white'>
|
||||
Interested in helping us develop UT Registration Plus? Check out our{' '}
|
||||
<Link className='text-ut-orange!' href={DISCORD_URL}>
|
||||
Discord Server
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link className='text-ut-orange!' href={GITHUB_URL}>
|
||||
GitHub
|
||||
</Link>
|
||||
!
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DesignerRecruitmentBanner = () => (
|
||||
<div className={styles.container}>
|
||||
<Text className='text-white'>
|
||||
Design for thousands of UT students through Longhorn Developers on real-world projects like UT Reg.
|
||||
Plus.—build your portfolio and collaborate in Figma. Apply{' '}
|
||||
<Link className='text-ut-orange!' href={DESIGNER_APPLICATION_URL}>
|
||||
here
|
||||
</Link>
|
||||
!
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* This adds a new column to the course catalog table header.
|
||||
@@ -17,47 +52,37 @@ const RECRUIT_FROM_DEPARTMENTS = ['C S', 'ECE', 'MIS', 'CSE', 'EE', 'ITD', 'DES'
|
||||
*/
|
||||
export default function RecruitmentBanner(): JSX.Element | null {
|
||||
const [container, setContainer] = useState<HTMLDivElement | null>(null);
|
||||
const recruitmentType = useMemo<RecruitmentType>(getRecruitmentType, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canRecruitFrom()) {
|
||||
if (recruitmentType === 'NONE') {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.setAttribute('id', 'ut-registration-plus-table-head');
|
||||
|
||||
const table = document.querySelector('table');
|
||||
table!.before(container);
|
||||
setContainer(container);
|
||||
}, []);
|
||||
}, [recruitmentType]);
|
||||
|
||||
if (!container) {
|
||||
if (!container || recruitmentType === 'NONE') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className={styles.container}>
|
||||
<Text color='white'>
|
||||
Interested in helping us develop UT Registration Plus? Check out our{' '}
|
||||
<Link color='white' href={DISCORD_URL}>
|
||||
Discord Server
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link color='white' href={GITHUB_URL}>
|
||||
GitHub
|
||||
</Link>
|
||||
!
|
||||
</Text>
|
||||
</div>,
|
||||
recruitmentType === 'DEVELOPER' ? <DeveloperRecruitmentBanner /> : <DesignerRecruitmentBanner />,
|
||||
container
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if recruitment can be done from the current department.
|
||||
* Determines what type of recruitment can be done from the current department.
|
||||
*
|
||||
* @returns True if recruitment can be done from the current department, false otherwise.
|
||||
* @returns 'DEVELOPER' or 'DESIGNER' if the current department recruits for that respective type, otherwise 'NONE'
|
||||
*/
|
||||
export const canRecruitFrom = (): boolean => {
|
||||
export const getRecruitmentType = (): RecruitmentType => {
|
||||
const params = ['fos_fl', 'fos_cn'];
|
||||
let department = '';
|
||||
params.forEach(p => {
|
||||
@@ -66,8 +91,18 @@ export const canRecruitFrom = (): boolean => {
|
||||
department = param;
|
||||
}
|
||||
});
|
||||
|
||||
if (!department) {
|
||||
return false;
|
||||
return 'NONE';
|
||||
}
|
||||
return RECRUIT_FROM_DEPARTMENTS.includes(department);
|
||||
|
||||
if (DEVELOPER_RECRUIT_FROM_DEPARTMENTS.has(department)) {
|
||||
return 'DEVELOPER';
|
||||
}
|
||||
|
||||
if (DESIGNER_RECRUIT_FROM_DEPARTMENTS.has(department)) {
|
||||
return 'DESIGNER';
|
||||
}
|
||||
|
||||
return 'NONE';
|
||||
};
|
||||
|
||||
39
src/views/components/injected/SearchResultShader.tsx
Normal file
39
src/views/components/injected/SearchResultShader.tsx
Normal 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;
|
||||
}
|
||||
@@ -1,16 +1,13 @@
|
||||
// import addCourse from '@pages/background/lib/addCourse';
|
||||
import { addCourseByURL } from '@pages/background/lib/addCourseByURL';
|
||||
import { deleteAllSchedules } from '@pages/background/lib/deleteSchedule';
|
||||
import exportSchedule from '@pages/background/lib/exportSchedule';
|
||||
import importSchedule from '@pages/background/lib/importSchedule';
|
||||
import { CalendarDots, Trash } from '@phosphor-icons/react';
|
||||
import { background } from '@shared/messages';
|
||||
import { DevStore } from '@shared/storage/DevStore';
|
||||
import { initSettings, OptionsStore } from '@shared/storage/OptionsStore';
|
||||
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
||||
import { CRX_PAGES } from '@shared/types/CRXPages';
|
||||
import MIMEType from '@shared/types/MIMEType';
|
||||
import { downloadBlob } from '@shared/util/downloadBlob';
|
||||
// import { addCourseByUrl } from '@shared/util/courseUtils';
|
||||
// import { getCourseColors } from '@shared/util/colors';
|
||||
// import CalendarCourseCell from '@views/components/calendar/CalendarCourseCell';
|
||||
@@ -32,6 +29,7 @@ import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import IconoirGitFork from '~icons/iconoir/git-fork';
|
||||
|
||||
import { handleExportJson } from '../calendar/utils';
|
||||
// import { ExampleCourse } from 'src/stories/components/ConflictsWithWarning.stories';;
|
||||
import FileUpload from '../common/FileUpload';
|
||||
import { useMigrationDialog } from '../common/MigrationDialog';
|
||||
@@ -232,18 +230,6 @@ export default function Settings(): JSX.Element {
|
||||
});
|
||||
};
|
||||
|
||||
const handleExportClick = async (id: string) => {
|
||||
const jsonString = await exportSchedule(id);
|
||||
if (jsonString) {
|
||||
const schedules = await UserScheduleStore.get('schedules');
|
||||
const schedule = schedules.find(s => s.id === id);
|
||||
const fileName = `${schedule?.name ?? `schedule_${id}`}_${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
|
||||
await downloadBlob(jsonString, 'JSON', fileName);
|
||||
} else {
|
||||
console.error('Error exporting schedule: jsonString is undefined');
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportClick = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
@@ -400,7 +386,7 @@ export default function Settings(): JSX.Element {
|
||||
<Button
|
||||
variant='outline'
|
||||
color='ut-burntorange'
|
||||
onClick={() => handleExportClick(activeSchedule.id)}
|
||||
onClick={() => handleExportJson(activeSchedule.id)}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
@@ -595,7 +581,9 @@ export default function Settings(): JSX.Element {
|
||||
|
||||
<section className='my-8 lg:my-0 lg:ml-4 lg:w-1/2'>
|
||||
<section>
|
||||
<h2 className='mb-4 text-xl text-ut-black font-semibold'>LONGHORN DEVELOPERS ADMINS</h2>
|
||||
<h2 className='mb-4 text-xl text-ut-black font-semibold'>
|
||||
LONGHORN DEVELOPERS (LHD) EXECUTIVE BOARD
|
||||
</h2>
|
||||
<div className='grid grid-cols-2 gap-4 2xl:grid-cols-4 md:grid-cols-3'>
|
||||
{LONGHORN_DEVELOPERS_ADMINS.map(admin => (
|
||||
<div
|
||||
@@ -611,7 +599,11 @@ export default function Settings(): JSX.Element {
|
||||
>
|
||||
{admin.name}
|
||||
</Text>
|
||||
<p className='text-sm text-gray-600'>{admin.role}</p>
|
||||
{admin.role.map(role => (
|
||||
<p key={admin.githubUsername} className='text-sm text-gray-600'>
|
||||
{role}
|
||||
</p>
|
||||
))}
|
||||
{showGitHubStats && githubStats && (
|
||||
<div className='mt-2'>
|
||||
<p className='text-xs text-gray-500'>GitHub Stats (UTRP repo):</p>
|
||||
@@ -657,7 +649,11 @@ export default function Settings(): JSX.Element {
|
||||
>
|
||||
{swe.name}
|
||||
</Text>
|
||||
<p className='text-sm text-gray-600'>{swe.role}</p>
|
||||
{swe.role.map(role => (
|
||||
<p key={swe.githubUsername} className='text-sm text-gray-600'>
|
||||
{role}
|
||||
</p>
|
||||
))}
|
||||
{showGitHubStats && githubStats && (
|
||||
<div className='mt-2'>
|
||||
<p className='text-xs text-gray-500'>GitHub Stats (UTRP repo):</p>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { convertMinutesToIndex } from '../useFlattenedCourseSchedule';
|
||||
describe('useFlattenedCourseSchedule', () => {
|
||||
it('should convert minutes to index correctly', () => {
|
||||
const minutes = 480; // 8:00 AM
|
||||
const expectedIndex = 2; // (480 - 420) / 30 = 2
|
||||
const expectedIndex = 3; // (480 - 480) / 30 + 2 + 1 = 3
|
||||
const result = convertMinutesToIndex(minutes);
|
||||
expect(result).toBe(expectedIndex);
|
||||
});
|
||||
|
||||
@@ -21,19 +21,19 @@ export function useEnforceScheduleLimit(): () => boolean {
|
||||
return useCallback(() => {
|
||||
if (schedules.length >= SCHEDULE_LIMIT) {
|
||||
showDialog({
|
||||
title: `You have ${SCHEDULE_LIMIT} active schedules!`,
|
||||
title: `You have too many schedules!`,
|
||||
|
||||
description: (
|
||||
<>
|
||||
To encourage organization,{' '}
|
||||
<span className='text-ut-burntorange'>please consider removing some unused schedules</span> you
|
||||
<span className='text-ut-burntorange'>please consider deleting any unused schedules</span> you
|
||||
may have.
|
||||
</>
|
||||
),
|
||||
|
||||
buttons: close => (
|
||||
<Button variant='filled' color='ut-burntorange' onClick={close}>
|
||||
I Understand
|
||||
I understand
|
||||
</Button>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface CalendarGridCourse {
|
||||
gridColumnStart?: number;
|
||||
gridColumnEnd?: number;
|
||||
totalColumns?: number;
|
||||
concurrentCells?: CalendarGridCourse[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,7 +53,9 @@ export interface FlattenedCourseSchedule {
|
||||
* @param minutes - The number of minutes.
|
||||
* @returns The index value.
|
||||
*/
|
||||
export const convertMinutesToIndex = (minutes: number): number => Math.floor((minutes - 420) / 30);
|
||||
export const convertMinutesToIndex = (minutes: number): number =>
|
||||
// 480 = 8 a.m., 30 = 30 minute slots, 2 header rows, and grid rows start at 1
|
||||
Math.floor((minutes - 480) / 30) + 2 + 1;
|
||||
|
||||
/**
|
||||
* Get the active schedule, and convert it to be render-able into a calendar.
|
||||
|
||||
54
src/views/hooks/useWhatsNew.tsx
Normal file
54
src/views/hooks/useWhatsNew.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
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 WhatsNewPopupContent from '@views/components/common/WhatsNewPopup';
|
||||
import { useDialog } from '@views/contexts/DialogContext';
|
||||
import React from 'react';
|
||||
|
||||
// import useChangelog from './useChangelog';
|
||||
|
||||
const LDIconURL = new URL('/src/assets/LD-icon-new.png', import.meta.url).href;
|
||||
|
||||
/**
|
||||
* Custom hook that provides a function to display a what's new dialog.
|
||||
*
|
||||
* @returns A function that, when called, shows a dialog with the changelog.
|
||||
*/
|
||||
export default function useWhatsNewPopUp(): () => void {
|
||||
const showDialog = useDialog();
|
||||
// const showChangeLog = useChangelog();
|
||||
// const { version } = chrome.runtime.getManifest();
|
||||
|
||||
const showPopUp = () => {
|
||||
showDialog(close => ({
|
||||
className: 'w-[830px] flex flex-col items-center gap-spacing-7 p-spacing-8',
|
||||
title: (
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
<img src={LDIconURL} alt='LD Icon' className='h-12 w-12 rounded-lg' />
|
||||
<Text variant='h1' className='text-theme-black'>
|
||||
Download our new UT Dining app!
|
||||
</Text>
|
||||
</div>
|
||||
),
|
||||
description: <WhatsNewPopupContent />,
|
||||
buttons: (
|
||||
<div className='flex flex-row items-end gap-spacing-4'>
|
||||
<Button
|
||||
onClick={() => {
|
||||
window.open(UT_DINING_APP_STORE_URL, '_blank');
|
||||
}}
|
||||
variant='minimal'
|
||||
color='ut-black'
|
||||
>
|
||||
Download UT Dining on iOS
|
||||
</Button>
|
||||
<Button onClick={close} color='ut-burntorange'>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
return showPopUp;
|
||||
}
|
||||
34
src/views/lib/CourseCatalogScraper.test.ts
Normal file
34
src/views/lib/CourseCatalogScraper.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { CourseCatalogScraper } from './CourseCatalogScraper';
|
||||
|
||||
describe('CourseCatalogScraper::separateCourseName', () => {
|
||||
it('should separate a simple course', () => {
|
||||
// UT Formats strings weird... lots of meaningless spaces
|
||||
const input = 'C S 314H DATA STRUCTURES: HONORS ';
|
||||
const expected = ['DATA STRUCTURES: HONORS', 'C S', '314H'];
|
||||
const result = CourseCatalogScraper.separateCourseName(input);
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('separate summer courses ', () => {
|
||||
// UT Formats strings weird... lots of meaningless spaces
|
||||
const inputs = [
|
||||
'P R f378 PUBLIC RELATNS TECHNIQUES-IRL (First term) ',
|
||||
'CRP s396 INDEPENDENT RESEARCH IN CRP (Second term) ',
|
||||
'B A n284S 1-MANAGERIAL MICROECON-I-DAL (Nine week term) ',
|
||||
'J w379 JOURNALISM INDEPENDENT STUDY (Whole term) ',
|
||||
];
|
||||
|
||||
const expected = [
|
||||
['PUBLIC RELATNS TECHNIQUES-IRL (First term)', 'P R', 'f378'],
|
||||
['INDEPENDENT RESEARCH IN CRP (Second term)', 'CRP', 's396'],
|
||||
['1-MANAGERIAL MICROECON-I-DAL (Nine week term)', 'B A', 'n284S'],
|
||||
['JOURNALISM INDEPENDENT STUDY (Whole term)', 'J', 'w379'],
|
||||
];
|
||||
const results = inputs.map(input => CourseCatalogScraper.separateCourseName(input));
|
||||
|
||||
expect(results).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -75,7 +75,7 @@ export class CourseCatalogScraper {
|
||||
|
||||
fullName = fullName.replace(/\s\s+/g, ' ').trim();
|
||||
|
||||
const [courseName, department, number] = this.separateCourseName(fullName);
|
||||
const [courseName, department, number] = CourseCatalogScraper.separateCourseName(fullName);
|
||||
const [status, isReserved] = this.getStatus(row);
|
||||
|
||||
const newCourse = new Course({
|
||||
@@ -113,16 +113,31 @@ export class CourseCatalogScraper {
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
* separateCourseName("CS 314H - Honors Discrete Structures") => ["Honors Discrete Structures", "CS", "314H"]
|
||||
* separateCourseName("C S 314H DATA STRUCTURES: HONORS") => ["DATA STRUCTURES: HONORS", "C S", "314H"]
|
||||
* ```
|
||||
* @param courseFullName - the full name of the course (e.g. "CS 314H - Honors Discrete Structures")
|
||||
* @returns an array of the course name , department, and number
|
||||
* @param courseFullName - the full name of the course (e.g. "C S 314H DATA STRUCTURES: HONORS")
|
||||
* @returns an array of the course name, department, and number
|
||||
*/
|
||||
separateCourseName(courseFullName: string): [courseName: string, department: string, number: string] {
|
||||
let courseNumberIndex = courseFullName.search(/\d/);
|
||||
let department = courseFullName.substring(0, courseNumberIndex).trim();
|
||||
let number = courseFullName.substring(courseNumberIndex, courseFullName.indexOf(' ', courseNumberIndex)).trim();
|
||||
let courseName = courseFullName.substring(courseFullName.indexOf(' ', courseNumberIndex)).trim();
|
||||
static separateCourseName(courseFullName: string): [courseName: string, department: string, number: string] {
|
||||
// C S 314H DATA STRUCTURES: HONORS
|
||||
// ^ Here for normal courses
|
||||
// B A n284S 1-MANAGERIAL MICROECON-I-DAL (Nine week term)
|
||||
// ^ Also works for summer courses ([f]irst term, [s]econd term, [n]ine week term, [w]hole term)
|
||||
const courseNumberIndex = courseFullName.search(/\w?\d/);
|
||||
|
||||
if (courseNumberIndex === -1) {
|
||||
throw new Error("Course name doesn't have a course number");
|
||||
}
|
||||
|
||||
// Everything before the course number
|
||||
const department = courseFullName.substring(0, courseNumberIndex).trim();
|
||||
|
||||
const number = courseFullName
|
||||
.substring(courseNumberIndex, courseFullName.indexOf(' ', courseNumberIndex))
|
||||
.trim();
|
||||
|
||||
// Everything after the course number
|
||||
const courseName = courseFullName.substring(courseFullName.indexOf(' ', courseNumberIndex)).trim();
|
||||
|
||||
return [courseName, department, number];
|
||||
}
|
||||
|
||||
@@ -109,16 +109,22 @@ function generateQuery(
|
||||
includeInstructor: boolean
|
||||
): [string, GradeDistributionParams] {
|
||||
const query = `
|
||||
select * from grade_distributions
|
||||
where Department_Code = :department_code
|
||||
and Course_Number = :course_number
|
||||
${includeInstructor ? `and Instructor_Last = :instructor_last collate nocase` : ''}
|
||||
${semester ? `and Semester = :semester` : ''}
|
||||
SELECT * FROM grade_distributions
|
||||
WHERE Department_Code = :department_code
|
||||
AND Course_Number COLLATE NOCASE IN (
|
||||
:course_number,
|
||||
concat('F', :course_number), -- Check summer courses with prefix, too
|
||||
concat('S', :course_number),
|
||||
concat('N', :course_number),
|
||||
concat('W', :course_number)
|
||||
)
|
||||
${includeInstructor ? `AND Instructor_Last = :instructor_last COLLATE NOCASE` : ''}
|
||||
${semester ? `AND Semester = :semester` : ''}
|
||||
`;
|
||||
|
||||
const params: GradeDistributionParams = {
|
||||
':department_code': course.department,
|
||||
':course_number': course.number,
|
||||
':course_number': course.getNumberWithoutTerm(),
|
||||
};
|
||||
|
||||
if (includeInstructor) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { CachedData } from '@shared/types/CachedData';
|
||||
// Types
|
||||
type TeamMember = {
|
||||
name: string;
|
||||
role: string;
|
||||
role: string[];
|
||||
githubUsername: string;
|
||||
};
|
||||
|
||||
@@ -40,21 +40,38 @@ const REPO_NAME = 'UT-Registration-Plus';
|
||||
const CONTRIBUTORS_API_ROUTE = `/repos/${REPO_OWNER}/${REPO_NAME}/stats/contributors`;
|
||||
|
||||
export const LONGHORN_DEVELOPERS_ADMINS = [
|
||||
{ name: 'Sriram Hariharan', role: 'Founder', githubUsername: 'sghsri' },
|
||||
{ name: 'Elie Soloveichik', role: 'Staff Engineer', githubUsername: 'Razboy20' },
|
||||
{ name: 'Diego Perez', role: 'Staff Engineer', githubUsername: 'doprz' },
|
||||
{ name: 'Lukas Zenick', role: 'Senior Software Engineer', githubUsername: 'Lukas-Zenick' },
|
||||
{ name: 'Isaiah Rodriguez', role: 'Chief Operations and Design Officer', githubUsername: 'IsaDavRod' },
|
||||
{ name: 'Samuel Gunter', role: 'Senior Software Engineer', githubUsername: 'Samathingamajig' },
|
||||
{ name: 'Derek Chen', role: 'Senior Software Engineer', githubUsername: 'DereC4' },
|
||||
{ name: 'Sriram Hariharan', role: ['LHD Co-Founder', 'UTRP Founder'], githubUsername: 'sghsri' },
|
||||
{
|
||||
name: 'Elie Soloveichik',
|
||||
role: ['LHD Co-Founder', 'Learning and Development Director', 'UTRP Senior SWE'],
|
||||
githubUsername: 'Razboy20',
|
||||
},
|
||||
{
|
||||
name: 'Diego Perez',
|
||||
role: ['LHD Co-Founder', 'Software Engineering Director', 'UTRP Senior SWE'],
|
||||
githubUsername: 'doprz',
|
||||
},
|
||||
{ name: 'Isaiah Rodriguez', role: ['LHD Co-Founder', 'President and UI/UX Director'], githubUsername: 'IsaDavRod' },
|
||||
{
|
||||
name: 'Samuel Gunter',
|
||||
role: ['Administrative Director', 'UTRP Co-Lead', 'UTRP Senior SWE'],
|
||||
githubUsername: 'Samathingamajig',
|
||||
},
|
||||
{
|
||||
name: 'Derek Chen',
|
||||
role: ['Communications Director', 'UTRP Co-Lead', 'UTRP Senior SWE'],
|
||||
githubUsername: 'DereC4',
|
||||
},
|
||||
{ name: 'Kabir Ramzan', role: ['Events Director'], githubUsername: 'CMEONE' },
|
||||
] as const satisfies TeamMember[];
|
||||
|
||||
export const LONGHORN_DEVELOPERS_SWE = [
|
||||
{ name: 'Preston Cook', role: 'Software Engineer', githubUsername: 'Preston-Cook' },
|
||||
{ name: 'Ethan Lanting', role: 'Software Engineer', githubUsername: 'EthanL06' },
|
||||
{ name: 'Casey Charleston', role: 'Software Engineer', githubUsername: 'caseycharleston' },
|
||||
{ name: 'Vinson', role: 'Software Engineer', githubUsername: 'vinsonzheng499' },
|
||||
{ name: 'Vivek', role: 'Software Engineer', githubUsername: 'vivek12311' },
|
||||
{ name: 'Preston Cook', role: ['Software Engineer'], githubUsername: 'Preston-Cook' },
|
||||
{ name: 'Ethan Lanting', role: ['Software Engineer'], githubUsername: 'EthanL06' },
|
||||
{ name: 'Casey Charleston', role: ['Software Engineer'], githubUsername: 'caseycharleston' },
|
||||
{ name: 'Lukas Zenick', role: ['LHD Alumni', 'Senior Software Engineer'], githubUsername: 'Lukas-Zenick' },
|
||||
{ name: 'Vinson', role: ['LHD Alumni', 'Software Engineer'], githubUsername: 'vinsonzheng499' },
|
||||
{ name: 'Vivek', role: ['LHD Alumni', 'Software Engineer'], githubUsername: 'vivek12311' },
|
||||
] as const satisfies TeamMember[];
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,7 +13,9 @@ export const SiteSupport = {
|
||||
MY_CALENDAR: 'MY_CALENDAR',
|
||||
REPORT_ISSUE: 'REPORT_ISSUE',
|
||||
MY_UT: 'MY_UT',
|
||||
COURSE_CATALOG_SEARCH: 'COURSE_CATALOG_SEARCH',
|
||||
CLASSLIST: 'CLASSLIST',
|
||||
COURSE_CATALOG_KWS: 'COURSE_CATALOG_KWS',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -39,12 +41,16 @@ export default function getSiteSupport(url: string): SiteSupportType | null {
|
||||
return SiteSupport.UT_PLANNER;
|
||||
}
|
||||
if (url.includes('utdirect.utexas.edu/apps/registrar/course_schedule')) {
|
||||
if (url.includes('kws_results')) {
|
||||
return SiteSupport.COURSE_CATALOG_KWS;
|
||||
}
|
||||
if (url.includes('results')) {
|
||||
return SiteSupport.COURSE_CATALOG_LIST;
|
||||
}
|
||||
if (document.querySelector('#details')) {
|
||||
return SiteSupport.COURSE_CATALOG_DETAILS;
|
||||
}
|
||||
return SiteSupport.COURSE_CATALOG_SEARCH;
|
||||
}
|
||||
if (url.includes('utdirect.utexas.edu') && (url.includes('waitlist') || url.includes('classlist'))) {
|
||||
return SiteSupport.WAITLIST;
|
||||
|
||||
@@ -26,7 +26,7 @@ export default defineConfig({
|
||||
],
|
||||
shortcuts: {
|
||||
focusable: 'outline-none ring-blue-500/50 dark:ring-blue-400/60 ring-0 focus-visible:ring-4',
|
||||
btn: 'h-10 w-auto flex cursor-pointer justify-center items-center gap-spacing-3 rounded-1 px-spacing-5 py-0 text-4.5 btn-transition disabled:(cursor-not-allowed opacity-50) active:enabled:scale-96 focusable',
|
||||
btn: 'h-10 w-auto flex cursor-pointer justify-center items-center gap-spacing-3 rounded-1 px-spacing-5 py-0 text-4.5 btn-transition disabled:(cursor-not-allowed opacity-50) active:enabled:scale-96 active:has-enabled:scale-96 focusable',
|
||||
link: 'text-ut-burntorange link:text-ut-burntorange underline underline-offset-2 hover:text-ut-orange focus-visible:text-ut-orange focusable btn-transition ease-out-expo',
|
||||
linkanimate:
|
||||
'relative cursor-pointer transition duration-100 ease-out after:(absolute left-0.4 right-0.4 h-2px scale-x-95 bg-ut-orange opacity-0 transition duration-250 ease-out-expo content-empty -bottom-0.75 -translate-y-0.5) active:scale-95 hover:text-ut-orange focus-visible:text-ut-orange hover:after:(opacity-100) !hover:after:translate-y-0 !hover:after:scale-x-100',
|
||||
|
||||
@@ -24,16 +24,14 @@ const pagesDir = resolve(root, 'pages');
|
||||
const assetsDir = resolve(root, 'assets');
|
||||
const publicDir = resolve(__dirname, 'public');
|
||||
|
||||
// Set default environment variables
|
||||
process.env.PROD = process.env.NODE_ENV === 'production' ? 'true' : 'false';
|
||||
|
||||
const isBeta = !!process.env.BETA;
|
||||
if (isBeta) {
|
||||
process.env.VITE_BETA_BUILD = 'true';
|
||||
}
|
||||
process.env.VITE_PACKAGE_VERSION = packageJson.version;
|
||||
// TODO: Debug this. If PROD is false, VITE_SENTRY_ENVIRONMENT is in production mode
|
||||
if (process.env.PROD) {
|
||||
|
||||
// special condition for production sentry instrumentation, as many of our devs like to use `pnpm build` directly. Production instrumentation is added and uploaded during `pnpm zip:to-publish`.
|
||||
if (process.env.SENTRY_ENV === 'production') {
|
||||
process.env.VITE_SENTRY_ENVIRONMENT = 'production';
|
||||
} else if (isBeta) {
|
||||
process.env.VITE_SENTRY_ENVIRONMENT = 'beta';
|
||||
|
||||
Reference in New Issue
Block a user