Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a4f40a765 | ||
|
|
d11d55db66 | ||
|
|
76b6aa7c15 | ||
|
|
70d4fecad6 | ||
|
|
c3fa91752c | ||
|
|
7c2beef193 | ||
|
|
630d0d80d2 | ||
|
|
695743104c | ||
|
|
d014244b28 | ||
|
|
5cd56259f7 | ||
|
|
fa9f78b46e | ||
|
|
4a5f67f0fd | ||
|
|
3bed9cc27f | ||
|
|
0dcae25b93 | ||
|
|
ca734dcd39 | ||
|
|
9448072112 | ||
|
|
b1e98ca9d7 | ||
|
|
f036d409e6 | ||
|
|
5493c63f18 | ||
|
|
6c3139bf0f | ||
|
|
28ebb69612 | ||
|
|
008cb40cb8 |
19
CHANGELOG.md
19
CHANGELOG.md
@@ -1,3 +1,22 @@
|
|||||||
|
## [2.2.0](https://github.com/Longhorn-Developers/UT-Registration-Plus/compare/v2.1.1...v2.2.0) (2025-04-06)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- auto create empty schedule when deleted all schedules ([#552](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/552)) ([7c2beef](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/7c2beef1930fbc887e8ec1aea789016b3150cd21))
|
||||||
|
- ensure unique splash text on schedule change ([#554](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/554)) ([9448072](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/94480721124e052426c1f3236e8605c7088df79c))
|
||||||
|
- implement a What's New prompt ([#539](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/539)) ([f036d40](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/f036d409e60a39fd1d3cb2f0db53a6056615f336))
|
||||||
|
- persist sidebar toggle state ([#569](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/569)) ([6957431](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/695743104c57951ba1957258c60c843f8fae793f))
|
||||||
|
- recruitment banner for designer ([#578](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/578)) ([70d4fec](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/70d4fecad61ec3cd3ba839de302fd851e075d073))
|
||||||
|
- rework start time to checkboxes ([#553](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/553)) ([ca734dc](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/ca734dcd39a433cfd2e930ea04adeba959b32c36))
|
||||||
|
- sticky calendar header and days ([#568](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/568)) ([fa9f78b](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/fa9f78b46e3a2270a44d4cc0691195a7c695cb93))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- ics calendar export dates ([#535](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/535)) ([4a5f67f](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/4a5f67f0fda9f0ef57f821e4b7a55d63f099f579))
|
||||||
|
- include logo in screenshot, fix screenshots on small/zoomed windows ([#579](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/579)) ([76b6aa7](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/76b6aa7c150299dfcfa4b3dc00ce2de32f90f75c))
|
||||||
|
- merge course labels across pages ([#541](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/541)) ([6c3139b](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/6c3139bf0f324c9a7be826b6c24e8bf142fc53b1))
|
||||||
|
- **schedule:** truncate long schedule names in popup ([#564](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/564)) ([3bed9cc](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/3bed9cc27febfe795af0766a913c4845e74cc2da))
|
||||||
|
|
||||||
## [2.1.1](https://github.com/Longhorn-Developers/UT-Registration-Plus/compare/v2.1.0...v2.1.1) (2025-03-03)
|
## [2.1.1](https://github.com/Longhorn-Developers/UT-Registration-Plus/compare/v2.1.0...v2.1.1) (2025-03-03)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ut-registration-plus",
|
"name": "ut-registration-plus",
|
||||||
"displayName": "UT Registration Plus",
|
"displayName": "UT Registration Plus",
|
||||||
"version": "2.1.1",
|
"version": "2.2.0",
|
||||||
"description": "UT Registration Plus is a Chrome extension that allows students to easily register for classes.",
|
"description": "UT Registration Plus is a Chrome extension that allows students to easily register for classes.",
|
||||||
"private": true,
|
"private": true,
|
||||||
"homepage": "https://github.com/Longhorn-Developers/UT-Registration-Plus",
|
"homepage": "https://github.com/Longhorn-Developers/UT-Registration-Plus",
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@date-fns/tz": "^1.2.0",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
@@ -35,11 +36,13 @@
|
|||||||
"@octokit/rest": "^21.1.1",
|
"@octokit/rest": "^21.1.1",
|
||||||
"@phosphor-icons/react": "^2.1.7",
|
"@phosphor-icons/react": "^2.1.7",
|
||||||
"@sentry/react": "^8.55.0",
|
"@sentry/react": "^8.55.0",
|
||||||
|
"@tanstack/react-query": "^5.69.0",
|
||||||
"@unocss/vite": "^0.63.6",
|
"@unocss/vite": "^0.63.6",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"chrome-extension-toolkit": "^0.0.54",
|
"chrome-extension-toolkit": "^0.0.54",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"conventional-changelog": "^6.0.0",
|
"conventional-changelog": "^6.0.0",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"highcharts": "^11.4.8",
|
"highcharts": "^11.4.8",
|
||||||
"highcharts-react-official": "^3.2.1",
|
"highcharts-react-official": "^3.2.1",
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
@@ -157,6 +160,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"volta": {
|
"volta": {
|
||||||
"node": "20.9.0"
|
"node": "20.9.0",
|
||||||
|
"pnpm": "10.6.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
34
pnpm-lock.yaml
generated
34
pnpm-lock.yaml
generated
@@ -19,6 +19,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@date-fns/tz':
|
||||||
|
specifier: ^1.2.0
|
||||||
|
version: 1.2.0
|
||||||
'@dnd-kit/core':
|
'@dnd-kit/core':
|
||||||
specifier: ^6.3.1
|
specifier: ^6.3.1
|
||||||
version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
@@ -43,6 +46,9 @@ importers:
|
|||||||
'@sentry/react':
|
'@sentry/react':
|
||||||
specifier: ^8.55.0
|
specifier: ^8.55.0
|
||||||
version: 8.55.0(react@18.3.1)
|
version: 8.55.0(react@18.3.1)
|
||||||
|
'@tanstack/react-query':
|
||||||
|
specifier: ^5.69.0
|
||||||
|
version: 5.69.0(react@18.3.1)
|
||||||
'@unocss/vite':
|
'@unocss/vite':
|
||||||
specifier: ^0.63.6
|
specifier: ^0.63.6
|
||||||
version: 0.63.6(patch_hash=9e2d2732a6e057a2ca90fba199730f252d8b4db8631b2c6ee0854fce7771bc95)(rollup@4.34.8)(typescript@5.7.3)(vite@5.4.14(@types/node@22.13.5)(sass@1.85.1)(terser@5.39.0))
|
version: 0.63.6(patch_hash=9e2d2732a6e057a2ca90fba199730f252d8b4db8631b2c6ee0854fce7771bc95)(rollup@4.34.8)(typescript@5.7.3)(vite@5.4.14(@types/node@22.13.5)(sass@1.85.1)(terser@5.39.0))
|
||||||
@@ -58,6 +64,9 @@ importers:
|
|||||||
conventional-changelog:
|
conventional-changelog:
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
version: 6.0.0(conventional-commits-filter@5.0.0)
|
version: 6.0.0(conventional-commits-filter@5.0.0)
|
||||||
|
date-fns:
|
||||||
|
specifier: ^4.1.0
|
||||||
|
version: 4.1.0
|
||||||
highcharts:
|
highcharts:
|
||||||
specifier: ^11.4.8
|
specifier: ^11.4.8
|
||||||
version: 11.4.8
|
version: 11.4.8
|
||||||
@@ -571,6 +580,9 @@ packages:
|
|||||||
'@crxjs/vite-plugin@2.0.0-beta.21':
|
'@crxjs/vite-plugin@2.0.0-beta.21':
|
||||||
resolution: {integrity: sha512-kSXgHHqCXASqJ8NmY94+KLGVwdtkJ0E7KsRQ+vbMpRliJ5ze0xnSk0l41p4txlUysmEoqaeo4Xb7rEFdcU2zjQ==}
|
resolution: {integrity: sha512-kSXgHHqCXASqJ8NmY94+KLGVwdtkJ0E7KsRQ+vbMpRliJ5ze0xnSk0l41p4txlUysmEoqaeo4Xb7rEFdcU2zjQ==}
|
||||||
|
|
||||||
|
'@date-fns/tz@1.2.0':
|
||||||
|
resolution: {integrity: sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==}
|
||||||
|
|
||||||
'@dnd-kit/accessibility@3.1.1':
|
'@dnd-kit/accessibility@3.1.1':
|
||||||
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1972,6 +1984,14 @@ packages:
|
|||||||
'@swc/types@0.1.18':
|
'@swc/types@0.1.18':
|
||||||
resolution: {integrity: sha512-NZghLaQvF3eFdj2DUjGkpwaunbZYaRcxciHINnwA4n3FrLAI8hKFOBqs2wkcOiLQfWkIdfuG6gBkNFrkPNji5g==}
|
resolution: {integrity: sha512-NZghLaQvF3eFdj2DUjGkpwaunbZYaRcxciHINnwA4n3FrLAI8hKFOBqs2wkcOiLQfWkIdfuG6gBkNFrkPNji5g==}
|
||||||
|
|
||||||
|
'@tanstack/query-core@5.69.0':
|
||||||
|
resolution: {integrity: sha512-Kn410jq6vs1P8Nm+ZsRj9H+U3C0kjuEkYLxbiCyn3MDEiYor1j2DGVULqAz62SLZtUZ/e9Xt6xMXiJ3NJ65WyQ==}
|
||||||
|
|
||||||
|
'@tanstack/react-query@5.69.0':
|
||||||
|
resolution: {integrity: sha512-Ift3IUNQqTcaFa1AiIQ7WCb/PPy8aexZdq9pZWLXhfLcLxH0+PZqJ2xFImxCpdDZrFRZhLJrh76geevS5xjRhA==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^18 || ^19
|
||||||
|
|
||||||
'@tanstack/react-virtual@3.13.2':
|
'@tanstack/react-virtual@3.13.2':
|
||||||
resolution: {integrity: sha512-LceSUgABBKF6HSsHK2ZqHzQ37IKV/jlaWbHm+NyTa3/WNb/JZVcThDuTainf+PixltOOcFCYXwxbLpOX9sCx+g==}
|
resolution: {integrity: sha512-LceSUgABBKF6HSsHK2ZqHzQ37IKV/jlaWbHm+NyTa3/WNb/JZVcThDuTainf+PixltOOcFCYXwxbLpOX9sCx+g==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3269,6 +3289,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
|
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
date-fns@4.1.0:
|
||||||
|
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
|
||||||
|
|
||||||
debug@2.6.9:
|
debug@2.6.9:
|
||||||
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
|
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -7365,6 +7388,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@date-fns/tz@1.2.0': {}
|
||||||
|
|
||||||
'@dnd-kit/accessibility@3.1.1(react@18.3.1)':
|
'@dnd-kit/accessibility@3.1.1(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
@@ -8607,6 +8632,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@swc/counter': 0.1.3
|
'@swc/counter': 0.1.3
|
||||||
|
|
||||||
|
'@tanstack/query-core@5.69.0': {}
|
||||||
|
|
||||||
|
'@tanstack/react-query@5.69.0(react@18.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/query-core': 5.69.0
|
||||||
|
react: 18.3.1
|
||||||
|
|
||||||
'@tanstack/react-virtual@3.13.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
'@tanstack/react-virtual@3.13.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tanstack/virtual-core': 3.13.2
|
'@tanstack/virtual-core': 3.13.2
|
||||||
@@ -10237,6 +10269,8 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
is-data-view: 1.0.2
|
is-data-view: 1.0.2
|
||||||
|
|
||||||
|
date-fns@4.1.0: {}
|
||||||
|
|
||||||
debug@2.6.9:
|
debug@2.6.9:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.0.0
|
ms: 2.0.0
|
||||||
|
|||||||
@@ -34,10 +34,8 @@ const splashText: string[] = [
|
|||||||
"The Block of Butter incident of '22",
|
"The Block of Butter incident of '22",
|
||||||
'Begun, the midterms have.',
|
'Begun, the midterms have.',
|
||||||
'You must construct additional schedules',
|
'You must construct additional schedules',
|
||||||
"Arrows of Christ vs Church of Scientology was the crossover we didn't know we needed",
|
|
||||||
'THE WALK SIGN IS ON TO CROSS GUADALUPE AND 21ST',
|
'THE WALK SIGN IS ON TO CROSS GUADALUPE AND 21ST',
|
||||||
'Pay attention. Might learn something.',
|
'Pay attention. Might learn something.',
|
||||||
'Long ago, apartment rates lived together in harmony. Then, everything changed when American Campus Communities Inc attacked.',
|
|
||||||
'Roll for Initiative!',
|
'Roll for Initiative!',
|
||||||
'The line at the on-campus Starbucks is longer than your course waitlist.',
|
'The line at the on-campus Starbucks is longer than your course waitlist.',
|
||||||
'The weather changes more often than your class schedule.',
|
'The weather changes more often than your class schedule.',
|
||||||
@@ -59,7 +57,7 @@ const splashText: string[] = [
|
|||||||
'follow @sghsri!',
|
'follow @sghsri!',
|
||||||
'Officially part of the SEC',
|
'Officially part of the SEC',
|
||||||
'Planner is now acquired by Plus',
|
'Planner is now acquired by Plus',
|
||||||
'Longhorn-Developers is the best UT Student Org',
|
'Longhorn Developers is the best UT Student Org',
|
||||||
'The Eiffel Tower is the UT Tower of Paris',
|
'The Eiffel Tower is the UT Tower of Paris',
|
||||||
'A pen and paper is old fashioned, but sometimes old ways are best',
|
'A pen and paper is old fashioned, but sometimes old ways are best',
|
||||||
'A heart is like bedrock, destroyable only by cheating',
|
'A heart is like bedrock, destroyable only by cheating',
|
||||||
@@ -69,13 +67,13 @@ const splashText: string[] = [
|
|||||||
'Almost Turing complete',
|
'Almost Turing complete',
|
||||||
'#BF5700',
|
'#BF5700',
|
||||||
'The waitlist is a lie!',
|
'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.',
|
'A CS major walks into a bar. The bar is empty because it is a CS major.',
|
||||||
'UT Registration Plus - The only thing that can make registration worse is not having it',
|
'UT Registration Plus - The only thing that can make registration worse is not having it',
|
||||||
'UT Registration Plus - We make registration slightly less painful. Slightly.',
|
'UT Registration Plus - We make registration slightly less painful. Slightly.',
|
||||||
'UT Registration Plus - Do you really want to figure out which professors will ruin your GPA by yourself?',
|
'UT Registration Plus - Do you really want to figure out which professors will ruin your GPA by yourself?',
|
||||||
'Ayo tf is a memory leak',
|
"Ayo what's is a memory leak",
|
||||||
"lowkey we never thought we'd get this far, how tf are 60k of you people using this",
|
"lowkey we never thought we'd get this far, how are 60k of you people using this",
|
||||||
"dang we're really out here making a splash",
|
"dang we're really out here making a splash",
|
||||||
"We'd make a joke about A&M, but we're not sure they can read",
|
"We'd make a joke about A&M, but we're not sure they can read",
|
||||||
"We've only caused one or two outages, we swear!",
|
"We've only caused one or two outages, we swear!",
|
||||||
@@ -113,7 +111,7 @@ const splashText: string[] = [
|
|||||||
"Stop trying to make UTRP happen, it's not going to happen!",
|
"Stop trying to make UTRP happen, it's not going to happen!",
|
||||||
'Befriend the raccoons on campus',
|
'Befriend the raccoons on campus',
|
||||||
`It's ${new Date().toLocaleString('en-US', { month: 'long', day: 'numeric' })} and OU still sucks`,
|
`It's ${new Date().toLocaleString('en-US', { month: 'long', day: 'numeric' })} and OU still sucks`,
|
||||||
'As seen on TV! ',
|
'As seen on TV!',
|
||||||
"Should you major in Compsci? well, here's a better question. do you wanna have a bad time?",
|
"Should you major in Compsci? well, here's a better question. do you wanna have a bad time?",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { ExtensionStore } from '@shared/storage/ExtensionStore';
|
import { ExtensionStore } from '@shared/storage/ExtensionStore';
|
||||||
|
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
||||||
|
|
||||||
|
import createSchedule from '../lib/createSchedule';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when the extension is updated (or when the extension is reloaded in development mode)
|
* Called when the extension is updated (or when the extension is reloaded in development mode)
|
||||||
@@ -8,4 +11,11 @@ export default async function onUpdate() {
|
|||||||
version: chrome.runtime.getManifest().version,
|
version: chrome.runtime.getManifest().version,
|
||||||
lastUpdate: Date.now(),
|
lastUpdate: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const schedules = await UserScheduleStore.get('schedules');
|
||||||
|
|
||||||
|
// By invariant, there must always be at least one schedule
|
||||||
|
if (schedules.length === 0) {
|
||||||
|
createSchedule('Schedule 1');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,5 +36,11 @@ export default async function createSchedule(scheduleName: string) {
|
|||||||
schedules.push(newSchedule);
|
schedules.push(newSchedule);
|
||||||
|
|
||||||
await UserScheduleStore.set('schedules', schedules);
|
await UserScheduleStore.set('schedules', schedules);
|
||||||
|
|
||||||
|
// If there is only one schedule, set the active index to the new schedule
|
||||||
|
if (schedules.length <= 1) {
|
||||||
|
await UserScheduleStore.set('activeIndex', 0);
|
||||||
|
}
|
||||||
|
|
||||||
return newSchedule.id;
|
return newSchedule.id;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ export default async function deleteSchedule(scheduleId: string): Promise<string
|
|||||||
schedules.splice(scheduleIndex, 1);
|
schedules.splice(scheduleIndex, 1);
|
||||||
await UserScheduleStore.set('schedules', schedules);
|
await UserScheduleStore.set('schedules', schedules);
|
||||||
|
|
||||||
|
// By invariant, there must always be at least one schedule
|
||||||
|
if (schedules.length === 0) {
|
||||||
|
createSchedule('Schedule 1');
|
||||||
|
}
|
||||||
|
|
||||||
let newActiveIndex = activeIndex;
|
let newActiveIndex = activeIndex;
|
||||||
if (scheduleIndex < activeIndex) {
|
if (scheduleIndex < activeIndex) {
|
||||||
newActiveIndex = activeIndex - 1;
|
newActiveIndex = activeIndex - 1;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Calendar from '@views/components/calendar/Calendar';
|
|||||||
import DialogProvider from '@views/components/common/DialogProvider/DialogProvider';
|
import DialogProvider from '@views/components/common/DialogProvider/DialogProvider';
|
||||||
import ExtensionRoot from '@views/components/common/ExtensionRoot/ExtensionRoot';
|
import ExtensionRoot from '@views/components/common/ExtensionRoot/ExtensionRoot';
|
||||||
import { MigrationDialog } from '@views/components/common/MigrationDialog';
|
import { MigrationDialog } from '@views/components/common/MigrationDialog';
|
||||||
|
import { WhatsNewDialog } from '@views/components/common/WhatsNewPopup';
|
||||||
import SentryProvider from '@views/contexts/SentryContext';
|
import SentryProvider from '@views/contexts/SentryContext';
|
||||||
import { MessageListener } from 'chrome-extension-toolkit';
|
import { MessageListener } from 'chrome-extension-toolkit';
|
||||||
import useKC_DABR_WASM from 'kc-dabr-wasm';
|
import useKC_DABR_WASM from 'kc-dabr-wasm';
|
||||||
@@ -34,6 +35,7 @@ export default function CalendarMain() {
|
|||||||
<ExtensionRoot className='h-full w-full'>
|
<ExtensionRoot className='h-full w-full'>
|
||||||
<DialogProvider>
|
<DialogProvider>
|
||||||
<MigrationDialog />
|
<MigrationDialog />
|
||||||
|
<WhatsNewDialog />
|
||||||
<Calendar />
|
<Calendar />
|
||||||
</DialogProvider>
|
</DialogProvider>
|
||||||
</ExtensionRoot>
|
</ExtensionRoot>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import CourseCatalogMain from '@views/components/CourseCatalogMain';
|
import CourseCatalogMain from '@views/components/CourseCatalogMain';
|
||||||
import InjectedButton from '@views/components/injected/AddAllButton';
|
import InjectedButton from '@views/components/injected/AddAllButton';
|
||||||
|
import DaysCheckbox from '@views/components/injected/DaysCheckbox';
|
||||||
import getSiteSupport, { SiteSupport } from '@views/lib/getSiteSupport';
|
import getSiteSupport, { SiteSupport } from '@views/lib/getSiteSupport';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
@@ -25,3 +26,7 @@ if (support === SiteSupport.COURSE_CATALOG_DETAILS || support === SiteSupport.CO
|
|||||||
if (support === SiteSupport.MY_UT) {
|
if (support === SiteSupport.MY_UT) {
|
||||||
renderComponent(InjectedButton);
|
renderComponent(InjectedButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (support === SiteSupport.COURSE_CATALOG_SEARCH) {
|
||||||
|
renderComponent(DaysCheckbox);
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ interface IExtensionStore {
|
|||||||
version: string;
|
version: string;
|
||||||
/** When was the last update */
|
/** When was the last update */
|
||||||
lastUpdate: number;
|
lastUpdate: number;
|
||||||
|
/** The last version of the "What's New" popup that was shown to the user */
|
||||||
|
lastWhatsNewPopupVersion: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ExtensionStore = createLocalStore<IExtensionStore>({
|
export const ExtensionStore = createLocalStore<IExtensionStore>({
|
||||||
version: chrome.runtime.getManifest().version,
|
version: chrome.runtime.getManifest().version,
|
||||||
lastUpdate: Date.now(),
|
lastUpdate: Date.now(),
|
||||||
|
lastWhatsNewPopupVersion: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
debugStore({ ExtensionStore });
|
debugStore({ ExtensionStore });
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ export interface IOptionsStore {
|
|||||||
|
|
||||||
/** whether we should open the calendar in a new tab; default is to focus an existing calendar tab */
|
/** whether we should open the calendar in a new tab; default is to focus an existing calendar tab */
|
||||||
alwaysOpenCalendarInNewTab: boolean;
|
alwaysOpenCalendarInNewTab: boolean;
|
||||||
|
|
||||||
|
/** whether the calendar sidebar should be shown when the calendar is opened */
|
||||||
|
showCalendarSidebar: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OptionsStore = createSyncStore<IOptionsStore>({
|
export const OptionsStore = createSyncStore<IOptionsStore>({
|
||||||
@@ -26,6 +29,7 @@ export const OptionsStore = createSyncStore<IOptionsStore>({
|
|||||||
enableScrollToLoad: true,
|
enableScrollToLoad: true,
|
||||||
enableDataRefreshing: false,
|
enableDataRefreshing: false,
|
||||||
alwaysOpenCalendarInNewTab: false,
|
alwaysOpenCalendarInNewTab: false,
|
||||||
|
showCalendarSidebar: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,6 +44,7 @@ export const initSettings = async () =>
|
|||||||
enableScrollToLoad: await OptionsStore.get('enableScrollToLoad'),
|
enableScrollToLoad: await OptionsStore.get('enableScrollToLoad'),
|
||||||
enableDataRefreshing: await OptionsStore.get('enableDataRefreshing'),
|
enableDataRefreshing: await OptionsStore.get('enableDataRefreshing'),
|
||||||
alwaysOpenCalendarInNewTab: await OptionsStore.get('alwaysOpenCalendarInNewTab'),
|
alwaysOpenCalendarInNewTab: await OptionsStore.get('alwaysOpenCalendarInNewTab'),
|
||||||
|
showCalendarSidebar: await OptionsStore.get('showCalendarSidebar'),
|
||||||
}) satisfies IOptionsStore;
|
}) satisfies IOptionsStore;
|
||||||
|
|
||||||
// Clothing retailer right
|
// Clothing retailer right
|
||||||
|
|||||||
@@ -48,3 +48,22 @@ export const ellipsify = (input: string, chars: number): string => {
|
|||||||
}
|
}
|
||||||
return ellipisifed;
|
return ellipisifed;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stringifies a list of items in English format.
|
||||||
|
*
|
||||||
|
* @param items - The list of items to stringify.
|
||||||
|
* @returns A string representation of the list in English format.
|
||||||
|
* @example
|
||||||
|
* englishStringifyList([]) // ''
|
||||||
|
* englishStringifyList(['Alice']) // 'Alice'
|
||||||
|
* englishStringifyList(['Alice', 'Bob']) // 'Alice and Bob'
|
||||||
|
* englishStringifyList(['Alice', 'Bob', 'Charlie']) // 'Alice, Bob, and Charlie'
|
||||||
|
*/
|
||||||
|
export const englishStringifyList = (items: readonly string[]): string => {
|
||||||
|
if (items.length === 0) return '';
|
||||||
|
if (items.length === 1) return items[0]!;
|
||||||
|
if (items.length === 2) return `${items[0]} and ${items[1]}`;
|
||||||
|
|
||||||
|
return `${items.slice(0, -1).join(', ')}, and ${items.at(-1)}`;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { capitalize, capitalizeFirstLetter, ellipsify } from '@shared/util/string';
|
import { capitalize, capitalizeFirstLetter, ellipsify, englishStringifyList } from '@shared/util/string';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
// TODO: Fix `string.ts` and `string.test.ts` to make the tests pass
|
// TODO: Fix `string.ts` and `string.test.ts` to make the tests pass
|
||||||
@@ -54,3 +54,49 @@ describe('ellipsify', () => {
|
|||||||
expect(ellipsify('', 5)).toBe('');
|
expect(ellipsify('', 5)).toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('englishStringifyList', () => {
|
||||||
|
it('should handle an empty array', () => {
|
||||||
|
const data = [] satisfies string[];
|
||||||
|
const result = englishStringifyList(data);
|
||||||
|
const expected = '';
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle 1 element', () => {
|
||||||
|
const data = ['Alice'] satisfies string[];
|
||||||
|
const result = englishStringifyList(data);
|
||||||
|
const expected = 'Alice';
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle 2 elements', () => {
|
||||||
|
const data = ['Alice', 'Bob'] satisfies string[];
|
||||||
|
const result = englishStringifyList(data);
|
||||||
|
const expected = 'Alice and Bob';
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle 3 elements', () => {
|
||||||
|
const data = ['Alice', 'Bob', 'Charlie'] satisfies string[];
|
||||||
|
const result = englishStringifyList(data);
|
||||||
|
const expected = 'Alice, Bob, and Charlie';
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle n elements', () => {
|
||||||
|
const testcases = [
|
||||||
|
{ data: [], expected: '' },
|
||||||
|
{ data: ['foo'], expected: 'foo' },
|
||||||
|
{ data: ['foo', 'bar'], expected: 'foo and bar' },
|
||||||
|
{ data: ['foo', 'bar', 'baz'], expected: 'foo, bar, and baz' },
|
||||||
|
{ data: ['a', 'b', 'c', 'd'], expected: 'a, b, c, and d' },
|
||||||
|
{ data: 'abcdefghijk'.split(''), expected: 'a, b, c, d, e, f, g, h, i, j, and k' },
|
||||||
|
] satisfies { data: string[]; expected: string }[];
|
||||||
|
|
||||||
|
for (const { data, expected } of testcases) {
|
||||||
|
const result = englishStringifyList(data);
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
describe('sleep', () => {
|
describe('sleep', () => {
|
||||||
it('should resolve after the specified number of milliseconds', async () => {
|
it('should resolve after the specified number of milliseconds', async () => {
|
||||||
const start = Date.now();
|
const start = performance.now();
|
||||||
const milliseconds = 1000;
|
const milliseconds = 1000;
|
||||||
await sleep(milliseconds);
|
await sleep(milliseconds);
|
||||||
const end = Date.now();
|
const end = performance.now();
|
||||||
const elapsed = end - start;
|
const elapsed = end - start;
|
||||||
expect(elapsed).toBeGreaterThanOrEqual(milliseconds);
|
// Flaky test due to JS's lack of precision in setTimeout,
|
||||||
|
// so we allow for a 1ms difference
|
||||||
|
expect(elapsed).toBeGreaterThanOrEqual(milliseconds - 1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
hours: 3,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const multiMeetingMultiInstructorCourse: Course = new Course({
|
||||||
|
colors: {
|
||||||
|
primaryColor: '#ef4444',
|
||||||
|
secondaryColor: '#991b1b',
|
||||||
|
},
|
||||||
|
core: [],
|
||||||
|
courseName: '44-REPORTING TEXAS',
|
||||||
|
creditHours: 3,
|
||||||
|
department: 'J',
|
||||||
|
description: [
|
||||||
|
"Contemporary social, professional, and intellectual concerns with the practice of journalism. Students work as online reporters, photographers, and editors for the School of Journalism's Reporting Texas Web site.",
|
||||||
|
'Prerequisite: Graduate standing; additional prerequisites vary with the topic.',
|
||||||
|
'Designed to accommodate 35 or fewer students. Course number may be repeated for credit when the topics vary.',
|
||||||
|
],
|
||||||
|
flags: [],
|
||||||
|
fullName: 'J 395 44-REPORTING TEXAS',
|
||||||
|
instructionMode: 'In Person',
|
||||||
|
instructors: [
|
||||||
|
{
|
||||||
|
firstName: 'JOHN',
|
||||||
|
fullName: 'SCHWARTZ, JOHN R',
|
||||||
|
lastName: 'SCHWARTZ',
|
||||||
|
middleInitial: 'R',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
firstName: 'JOHN',
|
||||||
|
fullName: 'BRIDGES, JOHN A III',
|
||||||
|
lastName: 'BRIDGES',
|
||||||
|
middleInitial: 'A',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isReserved: true,
|
||||||
|
number: '395',
|
||||||
|
schedule: {
|
||||||
|
meetings: [
|
||||||
|
{
|
||||||
|
days: ['Tuesday', 'Thursday'],
|
||||||
|
endTime: 660,
|
||||||
|
location: {
|
||||||
|
building: 'CMA',
|
||||||
|
room: '6.146',
|
||||||
|
},
|
||||||
|
startTime: 570,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
days: ['Friday'],
|
||||||
|
endTime: 960,
|
||||||
|
location: {
|
||||||
|
building: 'DMC',
|
||||||
|
room: '3.208',
|
||||||
|
},
|
||||||
|
startTime: 780,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
scrapedAt: 1742491957535,
|
||||||
|
semester: {
|
||||||
|
code: '20259',
|
||||||
|
season: 'Fall',
|
||||||
|
year: 2025,
|
||||||
|
},
|
||||||
|
status: 'OPEN',
|
||||||
|
uniqueId: 10335,
|
||||||
|
url: 'https://utdirect.utexas.edu/apps/registrar/course_schedule/20259/10335/',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const multiMeetingMultiInstructorSchedule: UserSchedule = new UserSchedule({
|
||||||
|
courses: [multiMeetingMultiInstructorCourse],
|
||||||
|
id: 'mmmis',
|
||||||
|
name: 'Multi Meeting Multi Instructor Schedule',
|
||||||
|
hours: 3,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const chatterjeeCS429Course: Course = new Course({
|
||||||
|
colors: {
|
||||||
|
primaryColor: '#0284c7',
|
||||||
|
secondaryColor: '#0c4a6e',
|
||||||
|
},
|
||||||
|
core: [],
|
||||||
|
courseName: 'COMP ORGANIZATN AND ARCH',
|
||||||
|
creditHours: 4,
|
||||||
|
department: 'C S',
|
||||||
|
description: [
|
||||||
|
'Restricted to computer science majors.',
|
||||||
|
'An introduction to low-level computer design ranging from the basics of digital design to the hardware/software interface for application programs. Includes basic systems principles of pipelining and caching, and requires writing and understanding programs at multiple levels.',
|
||||||
|
'Computer Science 429 and 429H may not both be counted.',
|
||||||
|
'Prerequisite: The following courses with a grade of at least C-: Computer Science 311 or 311H; and Computer Science 314 or 314H.',
|
||||||
|
],
|
||||||
|
flags: [],
|
||||||
|
fullName: 'C S 429 COMP ORGANIZATN AND ARCH',
|
||||||
|
instructionMode: 'In Person',
|
||||||
|
instructors: [
|
||||||
|
{
|
||||||
|
firstName: 'SIDDHARTHA',
|
||||||
|
fullName: 'CHATTERJEE, SIDDHARTHA',
|
||||||
|
lastName: 'CHATTERJEE',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isReserved: true,
|
||||||
|
number: '429',
|
||||||
|
schedule: {
|
||||||
|
meetings: [
|
||||||
|
{
|
||||||
|
days: ['Monday', 'Tuesday', 'Wednesday', 'Thursday'],
|
||||||
|
endTime: 1020,
|
||||||
|
location: {
|
||||||
|
building: 'UTC',
|
||||||
|
room: '3.102',
|
||||||
|
},
|
||||||
|
startTime: 960,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
days: ['Friday'],
|
||||||
|
endTime: 660,
|
||||||
|
location: {
|
||||||
|
building: 'GSB',
|
||||||
|
room: '2.122',
|
||||||
|
},
|
||||||
|
startTime: 540,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
scrapedAt: 1742496630445,
|
||||||
|
semester: {
|
||||||
|
code: '20259',
|
||||||
|
season: 'Fall',
|
||||||
|
year: 2025,
|
||||||
|
},
|
||||||
|
status: 'OPEN',
|
||||||
|
uniqueId: 54795,
|
||||||
|
url: 'https://utdirect.utexas.edu/apps/registrar/course_schedule/20259/54795/',
|
||||||
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { CourseCatalogScraper } from '@views/lib/CourseCatalogScraper';
|
|||||||
import getCourseTableRows from '@views/lib/getCourseTableRows';
|
import getCourseTableRows from '@views/lib/getCourseTableRows';
|
||||||
import type { SiteSupportType } from '@views/lib/getSiteSupport';
|
import type { SiteSupportType } from '@views/lib/getSiteSupport';
|
||||||
import { populateSearchInputs } from '@views/lib/populateSearchInputs';
|
import { populateSearchInputs } from '@views/lib/populateSearchInputs';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
support: Extract<SiteSupportType, 'COURSE_CATALOG_DETAILS' | 'COURSE_CATALOG_LIST'>;
|
support: Extract<SiteSupportType, 'COURSE_CATALOG_DETAILS' | 'COURSE_CATALOG_LIST'>;
|
||||||
@@ -27,6 +27,8 @@ export default function CourseCatalogMain({ support }: Props): JSX.Element | nul
|
|||||||
const [selectedCourse, setSelectedCourse] = useState<Course | null>(null);
|
const [selectedCourse, setSelectedCourse] = useState<Course | null>(null);
|
||||||
const [showPopup, setShowPopup] = useState(false);
|
const [showPopup, setShowPopup] = useState(false);
|
||||||
const [enableScrollToLoad, setEnableScrollToLoad] = useState<boolean>(false);
|
const [enableScrollToLoad, setEnableScrollToLoad] = useState<boolean>(false);
|
||||||
|
const prevCourseTitleRef = useRef<string | null>(null);
|
||||||
|
const tbody = document.querySelector('table tbody')!;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
populateSearchInputs();
|
populateSearchInputs();
|
||||||
@@ -43,6 +45,9 @@ export default function CourseCatalogMain({ support }: Props): JSX.Element | nul
|
|||||||
const ccs = new CourseCatalogScraper(support);
|
const ccs = new CourseCatalogScraper(support);
|
||||||
const scrapedRows = ccs.scrape(tableRows, true);
|
const scrapedRows = ccs.scrape(tableRows, true);
|
||||||
setRows(scrapedRows);
|
setRows(scrapedRows);
|
||||||
|
prevCourseTitleRef.current =
|
||||||
|
scrapedRows.findLast(row => row.course === null)?.element.querySelector('.course_header')?.textContent ??
|
||||||
|
null;
|
||||||
}, [support]);
|
}, [support]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -51,8 +56,17 @@ export default function CourseCatalogMain({ support }: Props): JSX.Element | nul
|
|||||||
|
|
||||||
const addRows = (newRows: ScrapedRow[]) => {
|
const addRows = (newRows: ScrapedRow[]) => {
|
||||||
newRows.forEach(row => {
|
newRows.forEach(row => {
|
||||||
document.querySelector('table tbody')!.appendChild(row.element);
|
const courseTitle = row.element.querySelector('.course_header')?.textContent ?? null;
|
||||||
|
if (row.course === null) {
|
||||||
|
if (courseTitle !== prevCourseTitleRef.current) {
|
||||||
|
tbody.appendChild(row.element);
|
||||||
|
prevCourseTitleRef.current = courseTitle;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tbody.appendChild(row.element);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setRows([...rows, ...newRows]);
|
setRows([...rows, ...newRows]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -66,11 +66,19 @@ export default function PopupMain(): JSX.Element {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const randomIndex = Math.floor(Math.random() * splashText.length);
|
setFunny(prevFunny => {
|
||||||
setFunny(
|
// Ensure that the next splash text is not the same as the previous one
|
||||||
splashText[randomIndex] ?? 'If you are seeing this, something has gone horribly wrong behind the scenes.'
|
const splashTextWithoutCurrent = splashText.filter(text => text !== prevFunny);
|
||||||
|
const randomIndex = Math.floor(Math.random() * splashTextWithoutCurrent.length);
|
||||||
|
|
||||||
|
return (
|
||||||
|
splashTextWithoutCurrent[randomIndex] ??
|
||||||
|
'If you are seeing this, something has gone horribly wrong behind the scenes.'
|
||||||
);
|
);
|
||||||
}, []);
|
});
|
||||||
|
|
||||||
|
// Generate a new splash text every time the active schedule changes
|
||||||
|
}, [activeSchedule.id]);
|
||||||
|
|
||||||
const handleOpenOptions = async () => {
|
const handleOpenOptions = async () => {
|
||||||
const url = chrome.runtime.getURL('/options.html');
|
const url = chrome.runtime.getURL('/options.html');
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Sidebar } from '@phosphor-icons/react';
|
import { Sidebar } from '@phosphor-icons/react';
|
||||||
import type { CalendarTabMessages } from '@shared/messages/CalendarMessages';
|
import type { CalendarTabMessages } from '@shared/messages/CalendarMessages';
|
||||||
|
import { OptionsStore } from '@shared/storage/OptionsStore';
|
||||||
import type { Course } from '@shared/types/Course';
|
import type { Course } from '@shared/types/Course';
|
||||||
import { CRX_PAGES } from '@shared/types/CRXPages';
|
import { CRX_PAGES } from '@shared/types/CRXPages';
|
||||||
import { openReportWindow } from '@shared/util/openReportWindow';
|
import { openReportWindow } from '@shared/util/openReportWindow';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import CalendarBottomBar from '@views/components/calendar/CalendarBottomBar';
|
import CalendarBottomBar from '@views/components/calendar/CalendarBottomBar';
|
||||||
import CalendarGrid from '@views/components/calendar/CalendarGrid';
|
import CalendarGrid from '@views/components/calendar/CalendarGrid';
|
||||||
import CalendarHeader from '@views/components/calendar/CalendarHeader/CalendarHeader';
|
import CalendarHeader from '@views/components/calendar/CalendarHeader/CalendarHeader';
|
||||||
@@ -13,8 +15,10 @@ import CourseCatalogInjectedPopup from '@views/components/injected/CourseCatalog
|
|||||||
import { CalendarContext } from '@views/contexts/CalendarContext';
|
import { CalendarContext } from '@views/contexts/CalendarContext';
|
||||||
import useCourseFromUrl from '@views/hooks/useCourseFromUrl';
|
import useCourseFromUrl from '@views/hooks/useCourseFromUrl';
|
||||||
import { useFlattenedCourseSchedule } from '@views/hooks/useFlattenedCourseSchedule';
|
import { useFlattenedCourseSchedule } from '@views/hooks/useFlattenedCourseSchedule';
|
||||||
|
import useWhatsNewPopUp from '@views/hooks/useWhatsNew';
|
||||||
import { MessageListener } from 'chrome-extension-toolkit';
|
import { MessageListener } from 'chrome-extension-toolkit';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import OutwardArrowIcon from '~icons/material-symbols/arrow-outward';
|
import OutwardArrowIcon from '~icons/material-symbols/arrow-outward';
|
||||||
@@ -27,13 +31,32 @@ import CalendarFooter from './CalendarFooter';
|
|||||||
/**
|
/**
|
||||||
* Calendar page component
|
* Calendar page component
|
||||||
*/
|
*/
|
||||||
export default function Calendar(): JSX.Element {
|
export default function Calendar(): ReactNode {
|
||||||
const { courseCells, activeSchedule } = useFlattenedCourseSchedule();
|
const { courseCells, activeSchedule } = useFlattenedCourseSchedule();
|
||||||
|
const asyncCourseCells = courseCells.filter(block => block.async);
|
||||||
|
const displayBottomBar = asyncCourseCells && asyncCourseCells.length > 0;
|
||||||
|
|
||||||
const [course, setCourse] = useState<Course | null>(useCourseFromUrl());
|
const [course, setCourse] = useState<Course | null>(useCourseFromUrl());
|
||||||
|
|
||||||
const [showPopup, setShowPopup] = useState<boolean>(course !== null);
|
const [showPopup, setShowPopup] = useState<boolean>(course !== null);
|
||||||
const [showSidebar, setShowSidebar] = useState<boolean>(true);
|
const showWhatsNewDialog = useWhatsNewPopUp();
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { data: showSidebar, isPending: isSidebarStatePending } = useQuery({
|
||||||
|
queryKey: ['settings', 'showCalendarSidebar'],
|
||||||
|
queryFn: () => OptionsStore.get('showCalendarSidebar'),
|
||||||
|
staleTime: Infinity, // Prevent loading state on refocus
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: setShowSidebar } = useMutation({
|
||||||
|
mutationKey: ['settings', 'showCalendarSidebar'],
|
||||||
|
mutationFn: async (showSidebar: boolean) => {
|
||||||
|
OptionsStore.set('showCalendarSidebar', showSidebar);
|
||||||
|
},
|
||||||
|
onSuccess: (_, showSidebar) => {
|
||||||
|
queryClient.setQueryData(['settings', 'showCalendarSidebar'], showSidebar);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const listener = new MessageListener<CalendarTabMessages>({
|
const listener = new MessageListener<CalendarTabMessages>({
|
||||||
@@ -59,10 +82,12 @@ export default function Calendar(): JSX.Element {
|
|||||||
if (course) setShowPopup(true);
|
if (course) setShowPopup(true);
|
||||||
}, [course]);
|
}, [course]);
|
||||||
|
|
||||||
|
if (isSidebarStatePending) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CalendarContext.Provider value>
|
<CalendarContext.Provider value>
|
||||||
<div className='h-full w-full flex flex-col'>
|
<div className='h-full w-full flex flex-col'>
|
||||||
<div className='h-screen flex overflow-auto'>
|
<div className='h-screen flex overflow-auto screenshot:calendar-target'>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'py-spacing-6 relative h-full min-h-screen w-full flex flex-none flex-col justify-between overflow-clip whitespace-nowrap border-r border-ut-offwhite/50 shadow-[2px_0_10px,rgba(214_210_196_/_.1)] motion-safe:duration-300 motion-safe:ease-out-expo motion-safe:transition-[max-width] screenshot:hidden',
|
'py-spacing-6 relative h-full min-h-screen w-full flex flex-none flex-col justify-between overflow-clip whitespace-nowrap border-r border-ut-offwhite/50 shadow-[2px_0_10px,rgba(214_210_196_/_.1)] motion-safe:duration-300 motion-safe:ease-out-expo motion-safe:transition-[max-width] screenshot:hidden',
|
||||||
@@ -99,6 +124,7 @@ export default function Calendar(): JSX.Element {
|
|||||||
<ResourceLinks />
|
<ResourceLinks />
|
||||||
<Divider orientation='horizontal' size='100%' />
|
<Divider orientation='horizontal' size='100%' />
|
||||||
{/* <TeamLinks /> */}
|
{/* <TeamLinks /> */}
|
||||||
|
<div className='flex flex-col gap-spacing-3'>
|
||||||
<a
|
<a
|
||||||
href={CRX_PAGES.REPORT}
|
href={CRX_PAGES.REPORT}
|
||||||
className='flex items-center gap-spacing-2 text-ut-burntorange underline-offset-2 hover:underline'
|
className='flex items-center gap-spacing-2 text-ut-burntorange underline-offset-2 hover:underline'
|
||||||
@@ -112,6 +138,20 @@ export default function Calendar(): JSX.Element {
|
|||||||
<Text variant='p'>Send us Feedback!</Text>
|
<Text variant='p'>Send us Feedback!</Text>
|
||||||
<OutwardArrowIcon className='h-4 w-4' />
|
<OutwardArrowIcon className='h-4 w-4' />
|
||||||
</a>
|
</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>
|
</div>
|
||||||
|
|
||||||
<CalendarFooter />
|
<CalendarFooter />
|
||||||
@@ -123,7 +163,7 @@ export default function Calendar(): JSX.Element {
|
|||||||
// scrollbarGutter: 'stable',
|
// scrollbarGutter: 'stable',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
className='h-full flex flex-grow flex-col overflow-x-scroll px-spacing-5'
|
className='z-1 h-full flex flex-grow flex-col overflow-x-scroll [&>*]:px-spacing-5'
|
||||||
>
|
>
|
||||||
<CalendarHeader
|
<CalendarHeader
|
||||||
sidebarOpen={showSidebar}
|
sidebarOpen={showSidebar}
|
||||||
@@ -131,7 +171,11 @@ export default function Calendar(): JSX.Element {
|
|||||||
setShowSidebar(!showSidebar);
|
setShowSidebar(!showSidebar);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className='min-h-2xl min-w-5xl flex-grow overflow-auto pl-spacing-3 pt-spacing-3 screenshot:min-h-xl'>
|
<div
|
||||||
|
className={clsx('min-h-2xl min-w-5xl flex-grow gap-0 pl-spacing-3 screenshot:min-h-xl', {
|
||||||
|
'screenshot:flex-grow-0': displayBottomBar, // html-to-image seems to have a bug with flex-grow
|
||||||
|
})}
|
||||||
|
>
|
||||||
<CalendarGrid courseCells={courseCells} setCourse={setCourse} />
|
<CalendarGrid courseCells={courseCells} setCourse={setCourse} />
|
||||||
</div>
|
</div>
|
||||||
<CalendarBottomBar courseCells={courseCells} setCourse={setCourse} />
|
<CalendarBottomBar courseCells={courseCells} setCourse={setCourse} />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Text from '@views/components/common/Text/Text';
|
|||||||
import { ColorPickerProvider } from '@views/contexts/ColorPickerContext';
|
import { ColorPickerProvider } from '@views/contexts/ColorPickerContext';
|
||||||
import type { CalendarGridCourse } from '@views/hooks/useFlattenedCourseSchedule';
|
import type { CalendarGridCourse } from '@views/hooks/useFlattenedCourseSchedule';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import CalendarCourseBlock from './CalendarCourseCell';
|
import CalendarCourseBlock from './CalendarCourseCell';
|
||||||
@@ -18,19 +19,15 @@ type CalendarBottomBarProps = {
|
|||||||
* @param courses - The list of courses to display in the calendar.
|
* @param courses - The list of courses to display in the calendar.
|
||||||
* @returns The rendered bottom bar component.
|
* @returns The rendered bottom bar component.
|
||||||
*/
|
*/
|
||||||
export default function CalendarBottomBar({ courseCells, setCourse }: CalendarBottomBarProps): JSX.Element {
|
export default function CalendarBottomBar({ courseCells, setCourse }: CalendarBottomBarProps): ReactNode {
|
||||||
const asyncCourseCells = courseCells?.filter(block => block.async);
|
const asyncCourseCells = courseCells?.filter(block => block.async);
|
||||||
const displayCourses = asyncCourseCells && asyncCourseCells.length > 0;
|
const displayCourses = asyncCourseCells && asyncCourseCells.length > 0;
|
||||||
|
|
||||||
|
if (!displayCourses) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='w-full flex pl-spacing-7 pr-spacing-3 pt-spacing-4'>
|
<div className='w-full flex pl-spacing-7 pr-spacing-3 pt-spacing-4'>
|
||||||
<div
|
<div className='flex flex-grow items-center gap-1 text-nowrap'>
|
||||||
className={clsx('flex flex-grow items-center gap-1 text-nowrap', {
|
|
||||||
'py-7.5': !displayCourses,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{displayCourses && (
|
|
||||||
<>
|
|
||||||
<Text variant='p' className='text-ut-black uppercase'>
|
<Text variant='p' className='text-ut-black uppercase'>
|
||||||
Async / Other
|
Async / Other
|
||||||
</Text>
|
</Text>
|
||||||
@@ -54,8 +51,6 @@ export default function CalendarBottomBar({ courseCells, setCourse }: CalendarBo
|
|||||||
})}
|
})}
|
||||||
</ColorPickerProvider>
|
</ColorPickerProvider>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import CalendarCourseCell from '@views/components/calendar/CalendarCourseCell';
|
|||||||
import Text from '@views/components/common/Text/Text';
|
import Text from '@views/components/common/Text/Text';
|
||||||
import { ColorPickerProvider } from '@views/contexts/ColorPickerContext';
|
import { ColorPickerProvider } from '@views/contexts/ColorPickerContext';
|
||||||
import type { CalendarGridCourse } from '@views/hooks/useFlattenedCourseSchedule';
|
import type { CalendarGridCourse } from '@views/hooks/useFlattenedCourseSchedule';
|
||||||
import React from 'react';
|
import React, { Fragment } from 'react';
|
||||||
|
|
||||||
import CalendarCell from './CalendarGridCell';
|
import CalendarCell from './CalendarGridCell';
|
||||||
|
|
||||||
@@ -30,13 +30,13 @@ function makeGridRow(row: number, cols: number): JSX.Element {
|
|||||||
const hour = hoursOfDay[row]!;
|
const hour = hoursOfDay[row]!;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Fragment key={row}>
|
||||||
<CalendarHour hour={hour} />
|
<CalendarHour hour={hour} />
|
||||||
<div className='grid-row-span-2 w-4 border-b border-r border-gray-300' />
|
<div className='grid-row-span-2 w-4 border-b border-r border-gray-300' />
|
||||||
{[...Array(cols).keys()].map(col => (
|
{[...Array(cols).keys()].map(col => (
|
||||||
<CalendarCell key={`${row}${col}`} row={row} col={col} />
|
<CalendarCell key={`${row}${col}`} row={row} col={col} />
|
||||||
))}
|
))}
|
||||||
</>
|
</Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,23 +56,40 @@ export default function CalendarGrid({
|
|||||||
setCourse,
|
setCourse,
|
||||||
}: React.PropsWithChildren<Props>): JSX.Element {
|
}: React.PropsWithChildren<Props>): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<div className='grid grid-cols-[auto_auto_repeat(5,1fr)] grid-rows-[auto_repeat(26,1fr)] h-full'>
|
<div className='grid grid-cols-[auto_auto_repeat(5,1fr)] grid-rows-[auto_auto_repeat(27,1fr)] h-full'>
|
||||||
|
{/* Cover top left corner of grid, so time gets cut off at the top of the partial border */}
|
||||||
|
<div className='sticky top-[85px] z-10 col-span-2 h-3 bg-white' />
|
||||||
{/* Displaying day labels */}
|
{/* Displaying day labels */}
|
||||||
<div />
|
|
||||||
<div className='w-4 border-b border-r border-gray-300' />
|
|
||||||
{daysOfWeek.map(day => (
|
{daysOfWeek.map(day => (
|
||||||
<div className='h-4 flex items-end justify-center border-b border-r border-gray-300 pb-1.5'>
|
<div
|
||||||
<Text key={day} variant='small' className='text-center text-ut-burntorange' as='div'>
|
// Full height with background to prevent grid lines from showing behind
|
||||||
|
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}
|
{day}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{/* empty slot, for alignment */}
|
||||||
|
<div />
|
||||||
|
{/* time tick for the first hour */}
|
||||||
|
<div className='h-4 w-4 self-end border-b border-r border-gray-300' />
|
||||||
{[...Array(13).keys()].map(i => makeGridRow(i, 5))}
|
{[...Array(13).keys()].map(i => makeGridRow(i, 5))}
|
||||||
<CalendarHour hour={21} />
|
<CalendarHour hour={21} />
|
||||||
{Array(6)
|
{Array(6)
|
||||||
.fill(1)
|
.fill(1)
|
||||||
.map(() => (
|
.map((_, i) => (
|
||||||
<div className='h-4 flex items-end justify-center border-r border-gray-300' />
|
// Key suppresses warning about duplicate keys,
|
||||||
|
// and index is fine because it doesn't change between renders
|
||||||
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
|
<div key={i} className='h-4 flex items-end justify-center border-r border-gray-300' />
|
||||||
))}
|
))}
|
||||||
<ColorPickerProvider>
|
<ColorPickerProvider>
|
||||||
{courseCells && <AccountForCourseConflicts courseCells={courseCells} setCourse={setCourse} />}
|
{courseCells && <AccountForCourseConflicts courseCells={courseCells} setCourse={setCourse} />}
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ function CalendarCell({ row, col }: Props): JSX.Element {
|
|||||||
<div
|
<div
|
||||||
className='h-full w-full flex items-center border-b border-r border-gray-300'
|
className='h-full w-full flex items-center border-b border-r border-gray-300'
|
||||||
style={{
|
style={{
|
||||||
gridColumn: col + 3,
|
gridColumn: col + 3, // start in the 3rd 1-index column
|
||||||
gridRow: `${2 * row + 2} / ${2 * row + 4}`,
|
gridRow: `${2 * row + 3} / ${2 * row + 5}`, // Span 2 rows, skip 2 header rows
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className='h-0 w-full border-t border-gray-300/25' />
|
<div className='h-0 w-full border-t border-gray-300/25' />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Button } from '@views/components/common/Button';
|
|||||||
import DialogProvider from '@views/components/common/DialogProvider/DialogProvider';
|
import DialogProvider from '@views/components/common/DialogProvider/DialogProvider';
|
||||||
import Divider from '@views/components/common/Divider';
|
import Divider from '@views/components/common/Divider';
|
||||||
import { ExtensionRootWrapper, styleResetClass } from '@views/components/common/ExtensionRoot/ExtensionRoot';
|
import { ExtensionRootWrapper, styleResetClass } from '@views/components/common/ExtensionRoot/ExtensionRoot';
|
||||||
|
import { LargeLogo } from '@views/components/common/LogoIcon';
|
||||||
import ScheduleTotalHoursAndCourses from '@views/components/common/ScheduleTotalHoursAndCourses';
|
import ScheduleTotalHoursAndCourses from '@views/components/common/ScheduleTotalHoursAndCourses';
|
||||||
import useSchedules from '@views/hooks/useSchedules';
|
import useSchedules from '@views/hooks/useSchedules';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
@@ -27,7 +28,7 @@ export default function CalendarHeader({ sidebarOpen, onSidebarToggle }: Calenda
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{ scrollbarGutter: 'stable' }}
|
style={{ scrollbarGutter: 'stable' }}
|
||||||
className='sticky left-0 right-0 min-h-[85px] flex items-center gap-5 overflow-x-scroll overflow-y-hidden pl-spacing-7 pt-spacing-5'
|
className='sticky left-0 right-0 top-0 z-10 min-h-[85px] flex items-center gap-5 overflow-x-scroll overflow-y-hidden bg-white pl-spacing-7 pt-spacing-5'
|
||||||
>
|
>
|
||||||
{!sidebarOpen && (
|
{!sidebarOpen && (
|
||||||
<Button
|
<Button
|
||||||
@@ -39,6 +40,9 @@ export default function CalendarHeader({ sidebarOpen, onSidebarToggle }: Calenda
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<LargeLogo className='hidden! screenshot:flex!' />
|
||||||
|
<Divider className='self-center hidden! screenshot:block!' size='2.5rem' orientation='vertical' />
|
||||||
|
|
||||||
<div className='min-w-[11.5rem] screenshot:transform-origin-left screenshot:scale-120'>
|
<div className='min-w-[11.5rem] screenshot:transform-origin-left screenshot:scale-120'>
|
||||||
<ScheduleTotalHoursAndCourses
|
<ScheduleTotalHoursAndCourses
|
||||||
scheduleName={activeSchedule.name}
|
scheduleName={activeSchedule.name}
|
||||||
@@ -62,7 +66,7 @@ export default function CalendarHeader({ sidebarOpen, onSidebarToggle }: Calenda
|
|||||||
className={clsx([
|
className={clsx([
|
||||||
styleResetClass,
|
styleResetClass,
|
||||||
'mt-spacing-3',
|
'mt-spacing-3',
|
||||||
'min-w-max cursor-pointer origin-top-right rounded bg-white p-1 text-black shadow-lg transition border border-ut-offwhite/50 focus:outline-none',
|
'min-w-max cursor-pointer origin-top-right rounded bg-white p-1 text-black shadow-lg transition border border-ut-offwhite/50 focus:outline-none z-20',
|
||||||
'data-[closed]:(opacity-0 scale-95)',
|
'data-[closed]:(opacity-0 scale-95)',
|
||||||
'data-[enter]:(ease-out-expo duration-150)',
|
'data-[enter]:(ease-out-expo duration-150)',
|
||||||
'data-[leave]:(ease-out duration-50)',
|
'data-[leave]:(ease-out duration-50)',
|
||||||
|
|||||||
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,31 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { tz } from '@date-fns/tz';
|
||||||
|
import { Course } from '@shared/types/Course';
|
||||||
|
import { UserSchedule } from '@shared/types/UserSchedule';
|
||||||
|
import type { Serialized } from 'chrome-extension-toolkit';
|
||||||
|
import { format as formatDate, parseISO } from 'date-fns';
|
||||||
|
import {
|
||||||
|
chatterjeeCS429Course,
|
||||||
|
multiMeetingMultiInstructorCourse,
|
||||||
|
multiMeetingMultiInstructorSchedule,
|
||||||
|
} from 'src/stories/injected/mocked';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { formatToHHMMSS } from './utils';
|
import { allDatesInRanges, formatToHHMMSS, meetingToIcsString, nextDayInclusive, scheduleToIcsString } from './utils';
|
||||||
|
|
||||||
|
// Do all timezone calculations relative to UT's timezone
|
||||||
|
const TIMEZONE = 'America/Chicago';
|
||||||
|
const TZ = tz(TIMEZONE);
|
||||||
|
|
||||||
|
// Date and datetime formats used by iCal
|
||||||
|
const ISO_DATE_FORMAT = 'yyyy-MM-dd';
|
||||||
|
const ISO_BASIC_DATETIME_FORMAT = "yyyyMMdd'T'HHmmss";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulate serialized class instance, without the class's methods
|
||||||
|
*
|
||||||
|
* serde <-- Serialize, Deserialize
|
||||||
|
*/
|
||||||
|
const serde = <T>(data: T) => JSON.parse(JSON.stringify(data)) as Serialized<T>;
|
||||||
|
|
||||||
describe('formatToHHMMSS', () => {
|
describe('formatToHHMMSS', () => {
|
||||||
it('should format minutes to HHMMSS format', () => {
|
it('should format minutes to HHMMSS format', () => {
|
||||||
@@ -24,3 +49,431 @@ describe('formatToHHMMSS', () => {
|
|||||||
expect(result).toBe(expected);
|
expect(result).toBe(expected);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('nextDayInclusive', () => {
|
||||||
|
it('should return the same date if the given day is the same as the target day', () => {
|
||||||
|
const date = parseISO('2024-01-01', { in: TZ }); // Monday
|
||||||
|
const day = 1; // Monday
|
||||||
|
const result = nextDayInclusive(date, day);
|
||||||
|
expect(formatDate(result, ISO_DATE_FORMAT)).toBe('2024-01-01');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the next day if the given day is not the same as the target day', () => {
|
||||||
|
const date = parseISO('2024-07-18', { in: TZ }); // Thursday
|
||||||
|
const day = 2; // Tuesday
|
||||||
|
const result = nextDayInclusive(date, day);
|
||||||
|
expect(formatDate(result, ISO_DATE_FORMAT)).toBe('2024-07-23');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should wrap around years', () => {
|
||||||
|
const date = parseISO('2025-12-28', { in: TZ }); // Sunday
|
||||||
|
const day = 5; // Friday
|
||||||
|
const result = nextDayInclusive(date, day);
|
||||||
|
expect(formatDate(result, ISO_DATE_FORMAT)).toBe('2026-01-02');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle leap day', () => {
|
||||||
|
const date = parseISO('2024-02-27', { in: TZ }); // Tuesday
|
||||||
|
const day = 4; // Thursday
|
||||||
|
const result = nextDayInclusive(date, day);
|
||||||
|
expect(formatDate(result, ISO_DATE_FORMAT)).toBe('2024-02-29');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle an entire week of inputs', () => {
|
||||||
|
const date = parseISO('2024-08-20', { in: TZ }); // Tuesday
|
||||||
|
const days = [0, 1, 2, 3, 4, 5, 6] as const;
|
||||||
|
const results = days.map(day => nextDayInclusive(date, day));
|
||||||
|
const resultsFormatted = results.map(result => formatDate(result, ISO_DATE_FORMAT));
|
||||||
|
const expectedResults = [
|
||||||
|
'2024-08-25',
|
||||||
|
'2024-08-26',
|
||||||
|
'2024-08-20', // Same date
|
||||||
|
'2024-08-21',
|
||||||
|
'2024-08-22',
|
||||||
|
'2024-08-23',
|
||||||
|
'2024-08-24',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let i = 0; i < days.length; i++) {
|
||||||
|
expect(resultsFormatted[i]).toBe(expectedResults[i]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should maintain hours/minutes/seconds', () => {
|
||||||
|
const date = parseISO('20250115T143021', { in: TZ }); // Wednesday
|
||||||
|
const days = [0, 1, 2, 3, 4, 5, 6] as const;
|
||||||
|
const results = days.map(day => nextDayInclusive(date, day));
|
||||||
|
const resultsFormatted = results.map(result => formatDate(result, ISO_BASIC_DATETIME_FORMAT));
|
||||||
|
const expectedResults = [
|
||||||
|
'20250119T143021',
|
||||||
|
'20250120T143021',
|
||||||
|
'20250121T143021',
|
||||||
|
'20250115T143021',
|
||||||
|
'20250116T143021',
|
||||||
|
'20250117T143021',
|
||||||
|
'20250118T143021',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let i = 0; i < days.length; i++) {
|
||||||
|
expect(resultsFormatted[i]).toBe(expectedResults[i]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('allDatesInRanges', () => {
|
||||||
|
it('should handle empty array', () => {
|
||||||
|
const dateRanges = [] satisfies string[];
|
||||||
|
const result = allDatesInRanges(dateRanges);
|
||||||
|
const expected = [] satisfies Date[];
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle a single date', () => {
|
||||||
|
const dateRanges = ['2025-03-14'] satisfies (string | [string, string])[];
|
||||||
|
const result = allDatesInRanges(dateRanges);
|
||||||
|
const expected = ['2025-03-14'].map(dateStr => parseISO(dateStr, { in: TZ })) satisfies Date[];
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle a single date', () => {
|
||||||
|
const dateRanges = ['2025-03-14'] satisfies string[];
|
||||||
|
const result = allDatesInRanges(dateRanges);
|
||||||
|
const expected = ['2025-03-14'].map(dateStr => parseISO(dateStr, { in: TZ })) satisfies Date[];
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle a single date range', () => {
|
||||||
|
const dateRanges = [['2025-03-14', '2025-03-19']] satisfies (string | [string, string])[];
|
||||||
|
const result = allDatesInRanges(dateRanges);
|
||||||
|
const expected = ['2025-03-14', '2025-03-15', '2025-03-16', '2025-03-17', '2025-03-18', '2025-03-19'].map(
|
||||||
|
dateStr => parseISO(dateStr, { in: TZ })
|
||||||
|
) satisfies Date[];
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple dates/date ranges', () => {
|
||||||
|
const dateRanges = [
|
||||||
|
'2025-02-14',
|
||||||
|
['2025-03-14', '2025-03-19'],
|
||||||
|
'2026-12-01',
|
||||||
|
['2026-12-03', '2026-12-05'],
|
||||||
|
] satisfies (string | [string, string])[];
|
||||||
|
const result = allDatesInRanges(dateRanges);
|
||||||
|
const expected = [
|
||||||
|
'2025-02-14', // '2025-02-14'
|
||||||
|
'2025-03-14', // ['2025-03-14', '2025-03-19']
|
||||||
|
'2025-03-15',
|
||||||
|
'2025-03-16',
|
||||||
|
'2025-03-17',
|
||||||
|
'2025-03-18',
|
||||||
|
'2025-03-19',
|
||||||
|
'2026-12-01', // '2026-12-01'
|
||||||
|
'2026-12-03', // ['2026-12-03', '2026-12-05'
|
||||||
|
'2026-12-04',
|
||||||
|
'2026-12-05',
|
||||||
|
].map(dateStr => parseISO(dateStr, { in: TZ })) satisfies Date[];
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle month-/year-spanning ranges', () => {
|
||||||
|
const dateRanges = [
|
||||||
|
['2023-02-27', '2023-03-02'],
|
||||||
|
['2023-12-27', '2024-01-03'],
|
||||||
|
] satisfies (string | [string, string])[];
|
||||||
|
const result = allDatesInRanges(dateRanges);
|
||||||
|
const expected = [
|
||||||
|
'2023-02-27', // ['2023-02-27', '2023-03-2']
|
||||||
|
'2023-02-28',
|
||||||
|
'2023-03-01',
|
||||||
|
'2023-03-02',
|
||||||
|
'2023-12-27', // ['2023-12-27', '2024-01-3']
|
||||||
|
'2023-12-28',
|
||||||
|
'2023-12-29',
|
||||||
|
'2023-12-30',
|
||||||
|
'2023-12-31',
|
||||||
|
'2024-01-01',
|
||||||
|
'2024-01-02',
|
||||||
|
'2024-01-03',
|
||||||
|
].map(dateStr => parseISO(dateStr, { in: TZ })) satisfies Date[];
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle leap years', () => {
|
||||||
|
const dateRanges = [
|
||||||
|
['2023-02-27', '2023-03-02'],
|
||||||
|
['2024-02-27', '2024-03-02'],
|
||||||
|
['2025-02-27', '2025-03-02'],
|
||||||
|
] satisfies (string | [string, string])[];
|
||||||
|
const result = allDatesInRanges(dateRanges);
|
||||||
|
const expected = [
|
||||||
|
'2023-02-27', // ['2023-02-27', '2023-03-2']
|
||||||
|
'2023-02-28',
|
||||||
|
'2023-03-01',
|
||||||
|
'2023-03-02',
|
||||||
|
'2024-02-27', // ['2024-02-27', '2024-03-2']
|
||||||
|
'2024-02-28',
|
||||||
|
'2024-02-29',
|
||||||
|
'2024-03-01',
|
||||||
|
'2024-03-02',
|
||||||
|
'2025-02-27', // ['2025-02-27', '2025-03-2']
|
||||||
|
'2025-02-28',
|
||||||
|
'2025-03-01',
|
||||||
|
'2025-03-02',
|
||||||
|
].map(dateStr => parseISO(dateStr, { in: TZ })) satisfies Date[];
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('meetingToIcsString', () => {
|
||||||
|
it('should handle a one-day meeting with one instructor', () => {
|
||||||
|
const course = serde(multiMeetingMultiInstructorCourse);
|
||||||
|
course.instructors = course.instructors.slice(0, 1);
|
||||||
|
const meeting = course.schedule.meetings[1]!;
|
||||||
|
const result = meetingToIcsString(course, meeting);
|
||||||
|
const expected = (
|
||||||
|
`BEGIN:VEVENT
|
||||||
|
DTSTART;TZID=America/Chicago:20250829T130000
|
||||||
|
DTEND;TZID=America/Chicago:20250829T160000
|
||||||
|
RRULE:FREQ=WEEKLY;BYDAY=FR;UNTIL=20251209T060000Z
|
||||||
|
EXDATE;TZID=America/Chicago:20251128T130000
|
||||||
|
` +
|
||||||
|
// Only skips one Thanksgiving break day
|
||||||
|
`SUMMARY:J 395 – 44-REPORTING TEXAS
|
||||||
|
LOCATION:DMC 3.208
|
||||||
|
DESCRIPTION:Unique number: 10335\\nTaught by John Schwartz
|
||||||
|
END:VEVENT`
|
||||||
|
).replaceAll(/^\s+/gm, '');
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle unique numbers below 5 digits', () => {
|
||||||
|
const course = serde(multiMeetingMultiInstructorCourse);
|
||||||
|
course.instructors = course.instructors.slice(0, 1);
|
||||||
|
course.uniqueId = 4269;
|
||||||
|
const meeting = course.schedule.meetings[1]!;
|
||||||
|
const result = meetingToIcsString(course, meeting);
|
||||||
|
const expected = `BEGIN:VEVENT
|
||||||
|
DTSTART;TZID=America/Chicago:20250829T130000
|
||||||
|
DTEND;TZID=America/Chicago:20250829T160000
|
||||||
|
RRULE:FREQ=WEEKLY;BYDAY=FR;UNTIL=20251209T060000Z
|
||||||
|
EXDATE;TZID=America/Chicago:20251128T130000
|
||||||
|
SUMMARY:J 395 – 44-REPORTING TEXAS
|
||||||
|
LOCATION:DMC 3.208
|
||||||
|
DESCRIPTION:Unique number: 04269\\nTaught by John Schwartz
|
||||||
|
END:VEVENT`.replaceAll(/^\s+/gm, '');
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle a one-day meeting with multiple instructors', () => {
|
||||||
|
const course = serde(multiMeetingMultiInstructorCourse);
|
||||||
|
const meeting = course.schedule.meetings[1]!;
|
||||||
|
const result = meetingToIcsString(course, meeting);
|
||||||
|
const expected = `BEGIN:VEVENT
|
||||||
|
DTSTART;TZID=America/Chicago:20250829T130000
|
||||||
|
DTEND;TZID=America/Chicago:20250829T160000
|
||||||
|
RRULE:FREQ=WEEKLY;BYDAY=FR;UNTIL=20251209T060000Z
|
||||||
|
EXDATE;TZID=America/Chicago:20251128T130000
|
||||||
|
SUMMARY:J 395 – 44-REPORTING TEXAS
|
||||||
|
LOCATION:DMC 3.208
|
||||||
|
DESCRIPTION:Unique number: 10335\\nTaught by John Schwartz and John Bridges
|
||||||
|
END:VEVENT`.replaceAll(/^\s+/gm, '');
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should gracefully error on an out of range semester code', () => {
|
||||||
|
const course = serde(multiMeetingMultiInstructorCourse);
|
||||||
|
const meeting = course.schedule.meetings[0]!;
|
||||||
|
vi.spyOn(console, 'error').mockReturnValue(undefined);
|
||||||
|
course.semester = {
|
||||||
|
season: 'Fall',
|
||||||
|
year: 2010,
|
||||||
|
code: '20109',
|
||||||
|
};
|
||||||
|
const result = meetingToIcsString(course, meeting);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(console.error).toBeCalledWith(
|
||||||
|
`No academic calendar found for semester code: 20109; course uniqueId: ${course.uniqueId}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle a multi-day meeting with multiple instructors', () => {
|
||||||
|
const course = serde(multiMeetingMultiInstructorCourse);
|
||||||
|
const meeting = course.schedule.meetings[0]!;
|
||||||
|
const result = meetingToIcsString(course, meeting);
|
||||||
|
const expected = `BEGIN:VEVENT
|
||||||
|
DTSTART;TZID=America/Chicago:20250826T093000
|
||||||
|
DTEND;TZID=America/Chicago:20250826T110000
|
||||||
|
RRULE:FREQ=WEEKLY;BYDAY=TU,TH;UNTIL=20251209T060000Z
|
||||||
|
EXDATE;TZID=America/Chicago:20251125T093000,20251127T093000
|
||||||
|
SUMMARY:J 395 – 44-REPORTING TEXAS
|
||||||
|
LOCATION:CMA 6.146
|
||||||
|
DESCRIPTION:Unique number: 10335\\nTaught by John Schwartz and John Bridges
|
||||||
|
END:VEVENT`.replaceAll(/^\s+/gm, '');
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scheduleToIcsString', () => {
|
||||||
|
it('should handle an empty schedule', () => {
|
||||||
|
const schedule = serde(
|
||||||
|
new UserSchedule({
|
||||||
|
courses: [],
|
||||||
|
hours: 0,
|
||||||
|
id: 'fajowe',
|
||||||
|
name: 'fajowe',
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const result = scheduleToIcsString(schedule);
|
||||||
|
const expected = `BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
CALSCALE:GREGORIAN
|
||||||
|
X-WR-CALNAME:My Schedule
|
||||||
|
END:VCALENDAR`.replaceAll(/^\s+/gm, '');
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle a schedule with courses but no meetings', () => {
|
||||||
|
const schedule = serde(
|
||||||
|
new UserSchedule({
|
||||||
|
courses: [
|
||||||
|
new Course({
|
||||||
|
...multiMeetingMultiInstructorCourse,
|
||||||
|
schedule: {
|
||||||
|
meetings: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
hours: 0,
|
||||||
|
id: 'fajowe',
|
||||||
|
name: 'fajowe',
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const result = scheduleToIcsString(schedule);
|
||||||
|
const expected = `BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
CALSCALE:GREGORIAN
|
||||||
|
X-WR-CALNAME:My Schedule
|
||||||
|
END:VCALENDAR`.replaceAll(/^\s+/gm, '');
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle a schedule with courses but out-of-range semester', () => {
|
||||||
|
vi.spyOn(console, 'error').mockReturnValue(undefined);
|
||||||
|
const schedule = serde(
|
||||||
|
new UserSchedule({
|
||||||
|
courses: [
|
||||||
|
new Course({
|
||||||
|
...multiMeetingMultiInstructorCourse,
|
||||||
|
semester: {
|
||||||
|
season: 'Fall',
|
||||||
|
year: 2010,
|
||||||
|
code: '20109',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
hours: 0,
|
||||||
|
id: 'fajowe',
|
||||||
|
name: 'fajowe',
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const result = scheduleToIcsString(schedule);
|
||||||
|
const expected = `BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
CALSCALE:GREGORIAN
|
||||||
|
X-WR-CALNAME:My Schedule
|
||||||
|
END:VCALENDAR`.replaceAll(/^\s+/gm, '');
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle a single course with multiple meetings', () => {
|
||||||
|
const schedule = serde(multiMeetingMultiInstructorSchedule);
|
||||||
|
const result = scheduleToIcsString(schedule);
|
||||||
|
const expected = `BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
CALSCALE:GREGORIAN
|
||||||
|
X-WR-CALNAME:My Schedule
|
||||||
|
BEGIN:VEVENT
|
||||||
|
DTSTART;TZID=America/Chicago:20250826T093000
|
||||||
|
DTEND;TZID=America/Chicago:20250826T110000
|
||||||
|
RRULE:FREQ=WEEKLY;BYDAY=TU,TH;UNTIL=20251209T060000Z
|
||||||
|
EXDATE;TZID=America/Chicago:20251125T093000,20251127T093000
|
||||||
|
SUMMARY:J 395 – 44-REPORTING TEXAS
|
||||||
|
LOCATION:CMA 6.146
|
||||||
|
DESCRIPTION:Unique number: 10335\\nTaught by John Schwartz and John Bridges
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
DTSTART;TZID=America/Chicago:20250829T130000
|
||||||
|
DTEND;TZID=America/Chicago:20250829T160000
|
||||||
|
RRULE:FREQ=WEEKLY;BYDAY=FR;UNTIL=20251209T060000Z
|
||||||
|
EXDATE;TZID=America/Chicago:20251128T130000
|
||||||
|
SUMMARY:J 395 – 44-REPORTING TEXAS
|
||||||
|
LOCATION:DMC 3.208
|
||||||
|
DESCRIPTION:Unique number: 10335\\nTaught by John Schwartz and John Bridges
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR`.replaceAll(/^\s+/gm, '');
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle a complex schedule', () => {
|
||||||
|
const schedule = serde(multiMeetingMultiInstructorSchedule);
|
||||||
|
schedule.courses.push(chatterjeeCS429Course);
|
||||||
|
const result = scheduleToIcsString(schedule);
|
||||||
|
const expected = (
|
||||||
|
`BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
CALSCALE:GREGORIAN
|
||||||
|
X-WR-CALNAME:My Schedule
|
||||||
|
BEGIN:VEVENT
|
||||||
|
DTSTART;TZID=America/Chicago:20250826T093000
|
||||||
|
DTEND;TZID=America/Chicago:20250826T110000
|
||||||
|
RRULE:FREQ=WEEKLY;BYDAY=TU,TH;UNTIL=20251209T060000Z
|
||||||
|
EXDATE;TZID=America/Chicago:20251125T093000,20251127T093000
|
||||||
|
SUMMARY:J 395 – 44-REPORTING TEXAS
|
||||||
|
LOCATION:CMA 6.146
|
||||||
|
DESCRIPTION:Unique number: 10335\\nTaught by John Schwartz and John Bridges
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
DTSTART;TZID=America/Chicago:20250829T130000
|
||||||
|
DTEND;TZID=America/Chicago:20250829T160000
|
||||||
|
RRULE:FREQ=WEEKLY;BYDAY=FR;UNTIL=20251209T060000Z
|
||||||
|
EXDATE;TZID=America/Chicago:20251128T130000
|
||||||
|
SUMMARY:J 395 – 44-REPORTING TEXAS
|
||||||
|
LOCATION:DMC 3.208
|
||||||
|
DESCRIPTION:Unique number: 10335\\nTaught by John Schwartz and John Bridges
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
DTSTART;TZID=America/Chicago:20250825T160000
|
||||||
|
DTEND;TZID=America/Chicago:20250825T170000
|
||||||
|
RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH;UNTIL=20251209T060000Z
|
||||||
|
` +
|
||||||
|
// Skips Labor Day and only relevant days of Thanksgiving
|
||||||
|
`EXDATE;TZID=America/Chicago:20250901T160000,20251124T160000,20251125T160000,20251126T160000,20251127T160000
|
||||||
|
SUMMARY:C S 429 – COMP ORGANIZATN AND ARCH
|
||||||
|
LOCATION:UTC 3.102
|
||||||
|
DESCRIPTION:Unique number: 54795\\nTaught by Siddhartha Chatterjee
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
DTSTART;TZID=America/Chicago:20250829T090000
|
||||||
|
DTEND;TZID=America/Chicago:20250829T110000
|
||||||
|
RRULE:FREQ=WEEKLY;BYDAY=FR;UNTIL=20251209T060000Z
|
||||||
|
EXDATE;TZID=America/Chicago:20251128T090000
|
||||||
|
SUMMARY:C S 429 – COMP ORGANIZATN AND ARCH
|
||||||
|
LOCATION:GSB 2.122
|
||||||
|
DESCRIPTION:Unique number: 54795\\nTaught by Siddhartha Chatterjee
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR`
|
||||||
|
).replaceAll(/^\s+/gm, '');
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,9 +1,36 @@
|
|||||||
|
import { tz, TZDate } from '@date-fns/tz';
|
||||||
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
||||||
|
import type { Course } from '@shared/types/Course';
|
||||||
|
import type { CourseMeeting } from '@shared/types/CourseMeeting';
|
||||||
|
import Instructor from '@shared/types/Instructor';
|
||||||
import type { UserSchedule } from '@shared/types/UserSchedule';
|
import type { UserSchedule } from '@shared/types/UserSchedule';
|
||||||
import { downloadBlob } from '@shared/util/downloadBlob';
|
import { downloadBlob } from '@shared/util/downloadBlob';
|
||||||
|
import { englishStringifyList } from '@shared/util/string';
|
||||||
import type { Serialized } from 'chrome-extension-toolkit';
|
import type { Serialized } from 'chrome-extension-toolkit';
|
||||||
|
import type { DateArg, Day } from 'date-fns';
|
||||||
|
import {
|
||||||
|
addDays,
|
||||||
|
eachDayOfInterval,
|
||||||
|
format as formatDate,
|
||||||
|
formatISO,
|
||||||
|
getDay,
|
||||||
|
nextDay,
|
||||||
|
parseISO,
|
||||||
|
set as setMultiple,
|
||||||
|
} from 'date-fns';
|
||||||
import { toBlob } from 'html-to-image';
|
import { toBlob } from 'html-to-image';
|
||||||
|
|
||||||
|
import { academicCalendars } from './academic-calendars';
|
||||||
|
|
||||||
|
// Do all timezone calculations relative to UT's timezone
|
||||||
|
const TIMEZONE_ID = 'America/Chicago';
|
||||||
|
const TZ = tz(TIMEZONE_ID);
|
||||||
|
|
||||||
|
// Datetime format used by iCal, not directly supported by date-fns
|
||||||
|
// (date-fns adds the timezone to the end, but iCal doesn't want it)
|
||||||
|
const ISO_BASIC_DATETIME_FORMAT = "yyyyMMdd'T'HHmmss";
|
||||||
|
|
||||||
|
// iCal uses two-letter codes for days of the week
|
||||||
export const CAL_MAP = {
|
export const CAL_MAP = {
|
||||||
Sunday: 'SU',
|
Sunday: 'SU',
|
||||||
Monday: 'MO',
|
Monday: 'MO',
|
||||||
@@ -14,6 +41,17 @@ export const CAL_MAP = {
|
|||||||
Saturday: 'SA',
|
Saturday: 'SA',
|
||||||
} as const satisfies Record<string, string>;
|
} as const satisfies Record<string, string>;
|
||||||
|
|
||||||
|
// Date objects' day field goes by index like this
|
||||||
|
const DAY_NAME_TO_NUMBER = {
|
||||||
|
Sunday: 0,
|
||||||
|
Monday: 1,
|
||||||
|
Tuesday: 2,
|
||||||
|
Wednesday: 3,
|
||||||
|
Thursday: 4,
|
||||||
|
Friday: 5,
|
||||||
|
Saturday: 6,
|
||||||
|
} as const satisfies Record<string, number>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves the schedule from the UserScheduleStore based on the active index.
|
* Retrieves the schedule from the UserScheduleStore based on the active index.
|
||||||
* @returns A promise that resolves to the retrieved schedule.
|
* @returns A promise that resolves to the retrieved schedule.
|
||||||
@@ -38,50 +76,186 @@ export const formatToHHMMSS = (minutes: number) => {
|
|||||||
return `${hours}${mins}00`;
|
return `${hours}${mins}00`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a date in the format YYYYMMDD'T'HHmmss, which is the format used by iCal.
|
||||||
|
*
|
||||||
|
* @param date - The date to format.
|
||||||
|
* @returns The formatted date string.
|
||||||
|
*/
|
||||||
|
const iCalDateFormat = <DateType extends Date>(date: DateArg<DateType>) =>
|
||||||
|
formatDate(date, ISO_BASIC_DATETIME_FORMAT, { in: TZ });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the next day of the given date, inclusive of the given day.
|
||||||
|
*
|
||||||
|
* If the given date is the given day, the same date is returned.
|
||||||
|
*
|
||||||
|
* For example, a Monday targeting a Wednesday will return the next Wednesday,
|
||||||
|
* but if it was targeting a Monday it would return the same date.
|
||||||
|
*
|
||||||
|
* @param date - The date to increment.
|
||||||
|
* @param day - The day to increment to. (0 = Sunday, 1 = Monday, etc.)
|
||||||
|
* @returns The next day of the given date, inclusive of the given day.
|
||||||
|
*/
|
||||||
|
export const nextDayInclusive = (date: Date, day: Day): TZDate => {
|
||||||
|
if (getDay(date, { in: TZ }) === day) {
|
||||||
|
return new TZDate(date, TIMEZONE_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextDay(date, day, { in: TZ });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an array of all the dates (as Date objects) in the given date ranges.
|
||||||
|
*
|
||||||
|
* @param dateRanges - An array of date ranges.
|
||||||
|
* Each date range can be a string (in which case it is interpreted as a single date)
|
||||||
|
* or an array of two strings (in which case it is interpreted as a date range, inclusive).
|
||||||
|
* @returns An array of all the dates (as Date objects) in the given date ranges.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* allDatesInRanges(['2025-01-01', ['2025-03-14', '2025-03-16']]) // ['2025-01-01', '2025-03-14', '2025-03-15', '2025-03-16'] (as Date objects)
|
||||||
|
*
|
||||||
|
* @remarks Does not remove duplicate dates.
|
||||||
|
*/
|
||||||
|
export const allDatesInRanges = (dateRanges: readonly (string | [string, string])[]): Date[] =>
|
||||||
|
dateRanges.flatMap(breakDate => {
|
||||||
|
if (Array.isArray(breakDate)) {
|
||||||
|
return eachDayOfInterval({
|
||||||
|
start: parseISO(breakDate[0], { in: TZ }),
|
||||||
|
end: parseISO(breakDate[1], { in: TZ }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseISO(breakDate, { in: TZ });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a VEVENT string for a meeting of a course.
|
||||||
|
*
|
||||||
|
* @param course - The course object
|
||||||
|
* @param meeting - The meeting object
|
||||||
|
* @returns A string representation of the meeting in the iCalendar format (ICS)
|
||||||
|
*/
|
||||||
|
export const meetingToIcsString = (course: Serialized<Course>, meeting: Serialized<CourseMeeting>): string | null => {
|
||||||
|
const { startTime, endTime, days, location } = meeting;
|
||||||
|
if (!course.semester.code) {
|
||||||
|
console.error(`No semester found for course uniqueId: ${course.uniqueId}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (days.length === 0) {
|
||||||
|
console.error(`No days found for course uniqueId: ${course.uniqueId}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(academicCalendars, course.semester.code)) {
|
||||||
|
console.error(
|
||||||
|
`No academic calendar found for semester code: ${course.semester.code}; course uniqueId: ${course.uniqueId}`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const academicCalendar = academicCalendars[course.semester.code as keyof typeof academicCalendars];
|
||||||
|
|
||||||
|
const startDate = nextDayInclusive(
|
||||||
|
parseISO(academicCalendar.firstClassDate, { in: TZ }),
|
||||||
|
DAY_NAME_TO_NUMBER[days[0]!]
|
||||||
|
);
|
||||||
|
|
||||||
|
const startTimeHours = Math.floor(startTime / 60);
|
||||||
|
const startTimeMinutes = startTime % 60;
|
||||||
|
const startTimeDate = setMultiple(startDate, { hours: startTimeHours, minutes: startTimeMinutes }, { in: TZ });
|
||||||
|
|
||||||
|
const endTimeHours = Math.floor(endTime / 60);
|
||||||
|
const endTimeMinutes = endTime % 60;
|
||||||
|
const endTimeDate = setMultiple(startDate, { hours: endTimeHours, minutes: endTimeMinutes }, { in: TZ });
|
||||||
|
|
||||||
|
const untilDate = addDays(parseISO(academicCalendar.lastClassDate, { in: TZ }), 1);
|
||||||
|
|
||||||
|
const daysNumSet = new Set(days.map(d => DAY_NAME_TO_NUMBER[d]));
|
||||||
|
const excludedDates = allDatesInRanges(academicCalendar.breakDates)
|
||||||
|
// Don't need to exclude Tues/Thurs if it's a MWF class, etc.
|
||||||
|
.filter(date => daysNumSet.has(getDay(date, { in: TZ }) as Day))
|
||||||
|
.map(date => setMultiple(date, { hours: startTimeHours, minutes: startTimeMinutes }, { in: TZ }));
|
||||||
|
|
||||||
|
const startDateFormatted = iCalDateFormat(startTimeDate);
|
||||||
|
const endDateFormatted = iCalDateFormat(endTimeDate);
|
||||||
|
// Convert days to ICS compatible format, e.g. MO,WE,FR
|
||||||
|
const icsDays = days.map(day => CAL_MAP[day]).join(',');
|
||||||
|
|
||||||
|
// per spec, UNTIL must be in UTC
|
||||||
|
const untilDateFormatted = formatISO(untilDate, { format: 'basic', in: tz('utc') });
|
||||||
|
const excludedDatesFormatted = excludedDates.map(date => iCalDateFormat(date));
|
||||||
|
|
||||||
|
const uniqueNumberFormatted = course.uniqueId.toString().padStart(5, '0');
|
||||||
|
|
||||||
|
// The list part of "Taught by Michael Scott and Siddhartha Chatterjee Beasley"
|
||||||
|
const instructorsFormatted = englishStringifyList(
|
||||||
|
course.instructors
|
||||||
|
.map(instructor => Instructor.prototype.toString.call(instructor, { format: 'first_last' }))
|
||||||
|
.filter(name => name !== '')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Construct event string
|
||||||
|
let icsString = 'BEGIN:VEVENT\n';
|
||||||
|
icsString += `DTSTART;TZID=${TIMEZONE_ID}:${startDateFormatted}\n`;
|
||||||
|
icsString += `DTEND;TZID=${TIMEZONE_ID}:${endDateFormatted}\n`;
|
||||||
|
icsString += `RRULE:FREQ=WEEKLY;BYDAY=${icsDays};UNTIL=${untilDateFormatted}\n`;
|
||||||
|
icsString += `EXDATE;TZID=${TIMEZONE_ID}:${excludedDatesFormatted.join(',')}\n`;
|
||||||
|
icsString += `SUMMARY:${course.department} ${course.number} \u2013 ${course.courseName}\n`;
|
||||||
|
|
||||||
|
if (location?.building || location?.building) {
|
||||||
|
const locationFormatted = `${location?.building ?? ''} ${location?.room ?? ''}`.trim();
|
||||||
|
icsString += `LOCATION:${locationFormatted}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
icsString += `DESCRIPTION:Unique number: ${uniqueNumberFormatted}`;
|
||||||
|
if (instructorsFormatted) {
|
||||||
|
// Newlines need to be double-escaped
|
||||||
|
icsString += `\\nTaught by ${instructorsFormatted}`;
|
||||||
|
}
|
||||||
|
icsString += '\n';
|
||||||
|
|
||||||
|
icsString += 'END:VEVENT';
|
||||||
|
|
||||||
|
return icsString;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a VCALENDAR string for a schedule of a user.
|
||||||
|
* @param schedule - The schedule object
|
||||||
|
* @returns A string representation of the schedule in the iCalendar format (ICS)
|
||||||
|
*/
|
||||||
|
export const scheduleToIcsString = (schedule: Serialized<UserSchedule>) => {
|
||||||
|
let icsString = 'BEGIN:VCALENDAR\nVERSION:2.0\nCALSCALE:GREGORIAN\nX-WR-CALNAME:My Schedule\n';
|
||||||
|
|
||||||
|
const vevents = schedule.courses
|
||||||
|
.flatMap(course => course.schedule.meetings.map(meeting => meetingToIcsString(course, meeting)))
|
||||||
|
.filter(event => event !== null)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
if (vevents.length > 0) {
|
||||||
|
icsString += `${vevents}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
icsString += 'END:VCALENDAR';
|
||||||
|
|
||||||
|
return icsString;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saves the current schedule as a calendar file in the iCalendar format (ICS).
|
* Saves the current schedule as a calendar file in the iCalendar format (ICS).
|
||||||
* Fetches the current active schedule and converts it into an ICS string.
|
* Fetches the current active schedule and converts it into an ICS string.
|
||||||
* Downloads the ICS file to the user's device.
|
* Downloads the ICS file to the user's device.
|
||||||
*/
|
*/
|
||||||
export const saveAsCal = async () => {
|
export const saveAsCal = async () => {
|
||||||
const schedule = await getSchedule(); // Assumes this fetches the current active schedule
|
const schedule = await getSchedule();
|
||||||
|
|
||||||
let icsString = 'BEGIN:VCALENDAR\nVERSION:2.0\nCALSCALE:GREGORIAN\nX-WR-CALNAME:My Schedule\n';
|
|
||||||
|
|
||||||
if (!schedule) {
|
if (!schedule) {
|
||||||
throw new Error('No schedule found');
|
throw new Error('No schedule found');
|
||||||
}
|
}
|
||||||
|
|
||||||
schedule.courses.forEach(course => {
|
const icsString = scheduleToIcsString(schedule);
|
||||||
course.schedule.meetings.forEach(meeting => {
|
|
||||||
const { startTime, endTime, days, location } = meeting;
|
|
||||||
|
|
||||||
// Format start and end times to HHMMSS
|
|
||||||
const formattedStartTime = formatToHHMMSS(startTime);
|
|
||||||
const formattedEndTime = formatToHHMMSS(endTime);
|
|
||||||
|
|
||||||
// Map days to ICS compatible format
|
|
||||||
console.log(days);
|
|
||||||
const icsDays = days.map(day => CAL_MAP[day]).join(',');
|
|
||||||
console.log(icsDays);
|
|
||||||
|
|
||||||
// Assuming course has date started and ended, adapt as necessary
|
|
||||||
// const year = new Date().getFullYear(); // Example year, adapt accordingly
|
|
||||||
// Example event date, adapt startDate according to your needs
|
|
||||||
const startDate = `20240101T${formattedStartTime}`;
|
|
||||||
const endDate = `20240101T${formattedEndTime}`;
|
|
||||||
|
|
||||||
icsString += `BEGIN:VEVENT\n`;
|
|
||||||
icsString += `DTSTART:${startDate}\n`;
|
|
||||||
icsString += `DTEND:${endDate}\n`;
|
|
||||||
icsString += `RRULE:FREQ=WEEKLY;BYDAY=${icsDays}\n`;
|
|
||||||
icsString += `SUMMARY:${course.fullName}\n`;
|
|
||||||
icsString += `LOCATION:${location?.building ?? ''} ${location?.room ?? ''}\n`;
|
|
||||||
icsString += `END:VEVENT\n`;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
icsString += 'END:VCALENDAR';
|
|
||||||
|
|
||||||
downloadBlob(icsString, 'CALENDAR', 'schedule.ics');
|
downloadBlob(icsString, 'CALENDAR', 'schedule.ics');
|
||||||
};
|
};
|
||||||
@@ -92,31 +266,38 @@ export const saveAsCal = async () => {
|
|||||||
* @param calendarRef - The reference to the calendar component.
|
* @param calendarRef - The reference to the calendar component.
|
||||||
*/
|
*/
|
||||||
export const saveCalAsPng = () => {
|
export const saveCalAsPng = () => {
|
||||||
|
const WIDTH_PX = 1165;
|
||||||
|
const HEIGHT_PX = 754;
|
||||||
|
const SCALE = 2;
|
||||||
|
|
||||||
const rootNode = document.createElement('div');
|
const rootNode = document.createElement('div');
|
||||||
rootNode.style.backgroundColor = 'white';
|
rootNode.style.backgroundColor = 'white';
|
||||||
rootNode.style.position = 'fixed';
|
rootNode.style.position = 'fixed';
|
||||||
rootNode.style.zIndex = '1000';
|
rootNode.style.zIndex = '1000';
|
||||||
rootNode.style.top = '-10000px';
|
rootNode.style.top = '-10000px';
|
||||||
rootNode.style.left = '-10000px';
|
rootNode.style.left = '-10000px';
|
||||||
rootNode.style.width = '1165px';
|
rootNode.style.width = `${WIDTH_PX}px`;
|
||||||
rootNode.style.height = '754px';
|
rootNode.style.height = `${HEIGHT_PX}px`;
|
||||||
document.body.appendChild(rootNode);
|
document.body.appendChild(rootNode);
|
||||||
|
|
||||||
const clonedNode = document.querySelector('#root')!.cloneNode(true) as HTMLDivElement;
|
const clonedNode = document.querySelector('#root')!.cloneNode(true) as HTMLDivElement;
|
||||||
clonedNode.style.backgroundColor = 'white';
|
clonedNode.style.backgroundColor = 'white';
|
||||||
(clonedNode.firstChild as HTMLDivElement).classList.add('screenshot-in-progress');
|
(clonedNode.firstChild as HTMLDivElement).classList.add('screenshot-in-progress');
|
||||||
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
const calendarTarget = clonedNode.querySelector('.screenshot\\:calendar-target') as HTMLDivElement;
|
||||||
requestAnimationFrame(async () => {
|
calendarTarget.style.width = `${WIDTH_PX}px`;
|
||||||
rootNode.appendChild(clonedNode);
|
calendarTarget.style.height = `${HEIGHT_PX}px`;
|
||||||
|
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
rootNode.appendChild(clonedNode);
|
||||||
|
requestAnimationFrame(async () => {
|
||||||
try {
|
try {
|
||||||
const screenshotBlob = await toBlob(clonedNode, {
|
const screenshotBlob = await toBlob(clonedNode, {
|
||||||
cacheBust: true,
|
cacheBust: true,
|
||||||
canvasWidth: 1165 * 2,
|
canvasWidth: WIDTH_PX * SCALE,
|
||||||
canvasHeight: 754 * 2,
|
canvasHeight: HEIGHT_PX * SCALE,
|
||||||
skipAutoScale: true,
|
skipAutoScale: true,
|
||||||
pixelRatio: 2,
|
pixelRatio: SCALE,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!screenshotBlob) {
|
if (!screenshotBlob) {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function usePrompt(): (info: PromptInfo, options?: DialogOptions) => void
|
|||||||
{info.description}
|
{info.description}
|
||||||
</Text>
|
</Text>
|
||||||
),
|
),
|
||||||
className: 'max-w-[400px] flex flex-col gap-2.5 p-6.25',
|
className: 'max-w-[415px] flex flex-col gap-2.5 p-6.25 border border-ut-offwhite/50',
|
||||||
},
|
},
|
||||||
options
|
options
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// import '@unocss/reset/tailwind-compat.css';
|
// import '@unocss/reset/tailwind-compat.css';
|
||||||
import 'uno.css';
|
import 'uno.css';
|
||||||
|
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import React, { forwardRef } from 'react';
|
import React, { forwardRef } from 'react';
|
||||||
|
|
||||||
@@ -8,6 +9,8 @@ import styles from './ExtensionRoot.module.scss';
|
|||||||
|
|
||||||
export const styleResetClass = styles.extensionRoot;
|
export const styleResetClass = styles.extensionRoot;
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A wrapper component for the extension elements that adds some basic styling to them
|
* A wrapper component for the extension elements that adds some basic styling to them
|
||||||
*/
|
*/
|
||||||
@@ -16,7 +19,9 @@ export default function ExtensionRoot(props: React.HTMLProps<HTMLDivElement>): J
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
<div className={clsx(styleResetClass, 'h-full', className)} {...others} />
|
<div className={clsx(styleResetClass, 'h-full', className)} {...others} />
|
||||||
|
</QueryClientProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export default function FileUpload({
|
|||||||
} satisfies React.CSSProperties
|
} satisfies React.CSSProperties
|
||||||
}
|
}
|
||||||
className={clsx(
|
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':
|
'text-white! bg-opacity-100 hover:enabled:shadow-md active:enabled:shadow-sm shadow-black/20':
|
||||||
variant === 'filled',
|
variant === 'filled',
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export function useMigrationDialog() {
|
|||||||
{
|
{
|
||||||
title: 'This extension has updated!',
|
title: 'This extension has updated!',
|
||||||
description:
|
description:
|
||||||
"You may have already began planning your Spring '25 schedule. Click the button below to transfer your saved schedules into a new schedule. (You may be required to login to the UT Registrar)",
|
"You may have already began planning your Fall '25 schedule. Click the button below to transfer your saved schedules into a new schedule. (You may be required to login to the UT Registrar)",
|
||||||
|
|
||||||
buttons: close => <MigrationButtons close={close} />,
|
buttons: close => <MigrationButtons close={close} />,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,8 +24,12 @@ export default function ScheduleDropdown({ defaultOpen, children }: ScheduleDrop
|
|||||||
{({ open }) => (
|
{({ open }) => (
|
||||||
<>
|
<>
|
||||||
<DisclosureButton className='w-full flex items-center border-none bg-transparent px-3.5 py-2.5 text-left'>
|
<DisclosureButton className='w-full flex items-center border-none bg-transparent px-3.5 py-2.5 text-left'>
|
||||||
<div className='flex-1'>
|
<div className='flex-1 min-w-0 overflow-hidden'>
|
||||||
<Text as='div' variant='h3' className='w-100% text-ut-burntorange normal-case!'>
|
<Text
|
||||||
|
as='div'
|
||||||
|
variant='h3'
|
||||||
|
className='w-full truncate whitespace-nowrap text-ut-burntorange normal-case!'
|
||||||
|
>
|
||||||
{activeSchedule ? activeSchedule.name : 'Schedule'}
|
{activeSchedule ? activeSchedule.name : 'Schedule'}
|
||||||
</Text>
|
</Text>
|
||||||
<div className='flex gap-2.5 text-theme-black leading-[75%]!'>
|
<div className='flex gap-2.5 text-theme-black leading-[75%]!'>
|
||||||
|
|||||||
@@ -109,12 +109,14 @@ export default function ScheduleListItem({ schedule, onClick }: ScheduleListItem
|
|||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
showDialog({
|
showDialog({
|
||||||
title: 'Are you sure?',
|
title: 'Delete schedule?',
|
||||||
description: (
|
description: (
|
||||||
<>
|
<>
|
||||||
<Text>Deleting</Text>
|
<Text>Deleting </Text>
|
||||||
<Text className='text-ut-burntorange'> {schedule.name} </Text>
|
<Text className='text-ut-burntorange'>{schedule.name}</Text>
|
||||||
<Text>is permanent and will remove all added courses from that schedule.</Text>
|
<Text> is permanent and will remove all added courses from </Text>
|
||||||
|
<Text className='text-ut-burntorange'>{schedule.name}</Text>
|
||||||
|
<Text>.</Text>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
// eslint-disable-next-line react/no-unstable-nested-components
|
// eslint-disable-next-line react/no-unstable-nested-components
|
||||||
@@ -126,12 +128,13 @@ export default function ScheduleListItem({ schedule, onClick }: ScheduleListItem
|
|||||||
<Button
|
<Button
|
||||||
variant='filled'
|
variant='filled'
|
||||||
color='theme-red'
|
color='theme-red'
|
||||||
|
icon={Trash}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
close();
|
close();
|
||||||
deleteSchedule(schedule.id);
|
deleteSchedule(schedule.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Delete Permanently
|
Delete permanently
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
148
src/views/components/common/WhatsNewPopup.tsx
Normal file
148
src/views/components/common/WhatsNewPopup.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import type { IconProps } from '@phosphor-icons/react';
|
||||||
|
import { CloudX, Copy, Exam, MapPinArea, Palette } from '@phosphor-icons/react';
|
||||||
|
import { ExtensionStore } from '@shared/storage/ExtensionStore';
|
||||||
|
import Text from '@views/components/common/Text/Text';
|
||||||
|
import useWhatsNewPopUp from '@views/hooks/useWhatsNew';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is the version of the 'What's New' features popup.
|
||||||
|
*
|
||||||
|
* It is used to check if the popup has already been shown to the user or not
|
||||||
|
*
|
||||||
|
* It should be incremented every time the "What's New" popup is updated.
|
||||||
|
*/
|
||||||
|
const WHATSNEW_POPUP_VERSION = 1;
|
||||||
|
|
||||||
|
const WHATSNEW_VIDEO_URL = 'https://cdn.longhorns.dev/whats-new-v2.1.2.mp4';
|
||||||
|
|
||||||
|
type Feature = {
|
||||||
|
id: string;
|
||||||
|
icon: React.ForwardRefExoticComponent<IconProps>;
|
||||||
|
title: string | JSX.Element;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NEW_FEATURES = [
|
||||||
|
{
|
||||||
|
id: 'custom-course-colors',
|
||||||
|
icon: Palette,
|
||||||
|
title: 'Custom Course Colors',
|
||||||
|
description: 'Paint your schedule in your favorite color theme',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'quick-copy',
|
||||||
|
icon: Copy,
|
||||||
|
title: 'Quick Copy',
|
||||||
|
description: 'Quickly copy a course unique number to your clipboard',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'updated-grades',
|
||||||
|
icon: Exam,
|
||||||
|
title: 'Updated Grades',
|
||||||
|
description: 'Fall 2024 grades are now available in the grade distribution',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ut-map',
|
||||||
|
icon: MapPinArea,
|
||||||
|
title: (
|
||||||
|
<div className='flex flex-row items-center'>
|
||||||
|
UTRP Map
|
||||||
|
<span className='mx-2 border border-ut-burntorange rounded px-2 py-0.5 text-xs text-ut-burntorange font-medium'>
|
||||||
|
BETA
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
description: 'Find directions to your classes with our beta map feature in the settings page',
|
||||||
|
},
|
||||||
|
] as const satisfies readonly Feature[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WhatsNewPopupContent component.
|
||||||
|
*
|
||||||
|
* This component displays the content of the WhatsNew dialog.
|
||||||
|
* It shows the new features that have been added to the extension.
|
||||||
|
*
|
||||||
|
* @returns A JSX of WhatsNewPopupContent component.
|
||||||
|
*/
|
||||||
|
export default function WhatsNewPopupContent(): JSX.Element {
|
||||||
|
const [videoError, setVideoError] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full flex flex-row justify-between'>
|
||||||
|
<div className='w-full flex flex-col-reverse items-center justify-between gap-spacing-7 md:flex-row'>
|
||||||
|
<div className='grid grid-cols-1 w-fit items-center gap-spacing-6 sm:grid-cols-2 md:w-[277px] md:flex md:flex-col md:flex-nowrap'>
|
||||||
|
{NEW_FEATURES.map(({ id, icon: Icon, title, description }) => (
|
||||||
|
<div key={id} className='w-full flex items-center justify-between gap-spacing-5'>
|
||||||
|
<Icon width='40' height='40' className='text-ut-burntorange' />
|
||||||
|
<div className='w-full flex flex-col gap-spacing-1'>
|
||||||
|
<Text variant='h4' className='text-ut-burntorange font-bold!'>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text variant='p' className='text-ut-black'>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className='h-full max-w-[464px] w-full flex items-center justify-center'>
|
||||||
|
{videoError ? (
|
||||||
|
<div className='h-full w-full flex items-center justify-center border border-ut-offwhite/50 rounded'>
|
||||||
|
<div className='flex flex-col items-center justify-center p-spacing-2'>
|
||||||
|
<CloudX size={52} className='text-ut-black/50' />
|
||||||
|
<Text variant='h4' className='text-center text-ut-black/50'>
|
||||||
|
Failed to load video. Please try again later.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<video
|
||||||
|
className='h-fit w-full flex items-center justify-center border border-ut-offwhite/50 rounded object-cover'
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
>
|
||||||
|
<source src={WHATSNEW_VIDEO_URL} type='video/mp4' onError={() => setVideoError(true)} />
|
||||||
|
</video>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WhatsNewDialog component.
|
||||||
|
*
|
||||||
|
* This component is responsible for checking if the extension has already been updated
|
||||||
|
* and if so, it displays the WhatsNew dialog. Then it updates the state to show that the
|
||||||
|
* dialog has been shown.
|
||||||
|
*
|
||||||
|
* @returns An empty fragment.
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
* The component uses the `useWhatsNew` hook to show the WhatsNew dialog and the
|
||||||
|
* `useEffect` hook to perform the check on component mount. It also uses the `ExtensionStore`
|
||||||
|
* to view the state of the dialog.
|
||||||
|
*/
|
||||||
|
export function WhatsNewDialog(): null {
|
||||||
|
const showPopUp = useWhatsNewPopUp();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkUpdate = async () => {
|
||||||
|
const version = await ExtensionStore.get('lastWhatsNewPopupVersion');
|
||||||
|
if (version !== WHATSNEW_POPUP_VERSION) {
|
||||||
|
await ExtensionStore.set('lastWhatsNewPopupVersion', WHATSNEW_POPUP_VERSION);
|
||||||
|
showPopUp();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkUpdate();
|
||||||
|
|
||||||
|
// This is on purpose
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -140,7 +140,7 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
|
|||||||
tickWidth: 1,
|
tickWidth: 1,
|
||||||
tickLength: 10,
|
tickLength: 10,
|
||||||
tickColor: '#9CADB7',
|
tickColor: '#9CADB7',
|
||||||
crosshair: { color: extendedColors.theme.offwhite },
|
crosshair: { color: `${extendedColors.theme.offwhite}50` },
|
||||||
lineColor: '#9CADB7',
|
lineColor: '#9CADB7',
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
|
|||||||
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 Link from '@views/components/common/Link';
|
||||||
import Text from '@views/components/common/Text/Text';
|
import Text from '@views/components/common/Text/Text';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import styles from './RecruitmentBanner.module.scss';
|
import styles from './RecruitmentBanner.module.scss';
|
||||||
|
|
||||||
const DISCORD_URL = 'https://discord.gg/7pQDBGdmb7';
|
const DISCORD_URL = 'https://discord.gg/7pQDBGdmb7';
|
||||||
const GITHUB_URL = 'https://github.com/Longhorn-Developers/UT-Registration-Plus';
|
const GITHUB_URL = 'https://github.com/Longhorn-Developers/UT-Registration-Plus';
|
||||||
|
const DESIGNER_APPLICATION_URL =
|
||||||
|
'https://docs.google.com/forms/d/e/1FAIpQLSdX1Bb37tW6s1bkdIW3GJoTGcM_Uc-2DzFOFMXxGdn1jZ3K1A/viewform';
|
||||||
|
|
||||||
const RECRUIT_FROM_DEPARTMENTS = ['C S', 'ECE', 'MIS', 'CSE', 'EE', 'ITD', 'DES'];
|
// The lists below _must_ be mutually exclusive
|
||||||
|
const DEVELOPER_RECRUIT_FROM_DEPARTMENTS = new Set(['C S', 'ECE', 'MIS', 'CSE', 'EE', 'ITD']);
|
||||||
|
const DESIGNER_RECRUIT_FROM_DEPARTMENTS = new Set(['I', 'DES', 'AET']);
|
||||||
|
|
||||||
|
type RecruitmentType = 'DEVELOPER' | 'DESIGNER' | 'NONE';
|
||||||
|
|
||||||
|
const DeveloperRecruitmentBanner = () => (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<Text className='text-white'>
|
||||||
|
Interested in helping us develop UT Registration Plus? Check out our{' '}
|
||||||
|
<Link className='text-ut-orange!' href={DISCORD_URL}>
|
||||||
|
Discord Server
|
||||||
|
</Link>{' '}
|
||||||
|
and{' '}
|
||||||
|
<Link className='text-ut-orange!' href={GITHUB_URL}>
|
||||||
|
GitHub
|
||||||
|
</Link>
|
||||||
|
!
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const DesignerRecruitmentBanner = () => (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<Text className='text-white'>
|
||||||
|
Design for thousands of UT students through Longhorn Developers on real-world projects like UT Reg.
|
||||||
|
Plus.—build your portfolio and collaborate in Figma. Apply{' '}
|
||||||
|
<Link className='text-ut-orange!' href={DESIGNER_APPLICATION_URL}>
|
||||||
|
here
|
||||||
|
</Link>
|
||||||
|
!
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This adds a new column to the course catalog table header.
|
* This adds a new column to the course catalog table header.
|
||||||
@@ -17,47 +52,37 @@ const RECRUIT_FROM_DEPARTMENTS = ['C S', 'ECE', 'MIS', 'CSE', 'EE', 'ITD', 'DES'
|
|||||||
*/
|
*/
|
||||||
export default function RecruitmentBanner(): JSX.Element | null {
|
export default function RecruitmentBanner(): JSX.Element | null {
|
||||||
const [container, setContainer] = useState<HTMLDivElement | null>(null);
|
const [container, setContainer] = useState<HTMLDivElement | null>(null);
|
||||||
|
const recruitmentType = useMemo<RecruitmentType>(getRecruitmentType, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canRecruitFrom()) {
|
if (recruitmentType === 'NONE') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.setAttribute('id', 'ut-registration-plus-table-head');
|
container.setAttribute('id', 'ut-registration-plus-table-head');
|
||||||
|
|
||||||
const table = document.querySelector('table');
|
const table = document.querySelector('table');
|
||||||
table!.before(container);
|
table!.before(container);
|
||||||
setContainer(container);
|
setContainer(container);
|
||||||
}, []);
|
}, [recruitmentType]);
|
||||||
|
|
||||||
if (!container) {
|
if (!container || recruitmentType === 'NONE') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className={styles.container}>
|
recruitmentType === 'DEVELOPER' ? <DeveloperRecruitmentBanner /> : <DesignerRecruitmentBanner />,
|
||||||
<Text color='white'>
|
|
||||||
Interested in helping us develop UT Registration Plus? Check out our{' '}
|
|
||||||
<Link color='white' href={DISCORD_URL}>
|
|
||||||
Discord Server
|
|
||||||
</Link>{' '}
|
|
||||||
and{' '}
|
|
||||||
<Link color='white' href={GITHUB_URL}>
|
|
||||||
GitHub
|
|
||||||
</Link>
|
|
||||||
!
|
|
||||||
</Text>
|
|
||||||
</div>,
|
|
||||||
container
|
container
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines if recruitment can be done from the current department.
|
* Determines what type of recruitment can be done from the current department.
|
||||||
*
|
*
|
||||||
* @returns True if recruitment can be done from the current department, false otherwise.
|
* @returns 'DEVELOPER' or 'DESIGNER' if the current department recruits for that respective type, otherwise 'NONE'
|
||||||
*/
|
*/
|
||||||
export const canRecruitFrom = (): boolean => {
|
export const getRecruitmentType = (): RecruitmentType => {
|
||||||
const params = ['fos_fl', 'fos_cn'];
|
const params = ['fos_fl', 'fos_cn'];
|
||||||
let department = '';
|
let department = '';
|
||||||
params.forEach(p => {
|
params.forEach(p => {
|
||||||
@@ -66,8 +91,18 @@ export const canRecruitFrom = (): boolean => {
|
|||||||
department = param;
|
department = param;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!department) {
|
if (!department) {
|
||||||
return false;
|
return 'NONE';
|
||||||
}
|
}
|
||||||
return RECRUIT_FROM_DEPARTMENTS.includes(department);
|
|
||||||
|
if (DEVELOPER_RECRUIT_FROM_DEPARTMENTS.has(department)) {
|
||||||
|
return 'DEVELOPER';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DESIGNER_RECRUIT_FROM_DEPARTMENTS.has(department)) {
|
||||||
|
return 'DESIGNER';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'NONE';
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -595,7 +595,9 @@ export default function Settings(): JSX.Element {
|
|||||||
|
|
||||||
<section className='my-8 lg:my-0 lg:ml-4 lg:w-1/2'>
|
<section className='my-8 lg:my-0 lg:ml-4 lg:w-1/2'>
|
||||||
<section>
|
<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'>
|
<div className='grid grid-cols-2 gap-4 2xl:grid-cols-4 md:grid-cols-3'>
|
||||||
{LONGHORN_DEVELOPERS_ADMINS.map(admin => (
|
{LONGHORN_DEVELOPERS_ADMINS.map(admin => (
|
||||||
<div
|
<div
|
||||||
@@ -611,7 +613,11 @@ export default function Settings(): JSX.Element {
|
|||||||
>
|
>
|
||||||
{admin.name}
|
{admin.name}
|
||||||
</Text>
|
</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 && (
|
{showGitHubStats && githubStats && (
|
||||||
<div className='mt-2'>
|
<div className='mt-2'>
|
||||||
<p className='text-xs text-gray-500'>GitHub Stats (UTRP repo):</p>
|
<p className='text-xs text-gray-500'>GitHub Stats (UTRP repo):</p>
|
||||||
@@ -657,7 +663,11 @@ export default function Settings(): JSX.Element {
|
|||||||
>
|
>
|
||||||
{swe.name}
|
{swe.name}
|
||||||
</Text>
|
</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 && (
|
{showGitHubStats && githubStats && (
|
||||||
<div className='mt-2'>
|
<div className='mt-2'>
|
||||||
<p className='text-xs text-gray-500'>GitHub Stats (UTRP repo):</p>
|
<p className='text-xs text-gray-500'>GitHub Stats (UTRP repo):</p>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { convertMinutesToIndex } from '../useFlattenedCourseSchedule';
|
|||||||
describe('useFlattenedCourseSchedule', () => {
|
describe('useFlattenedCourseSchedule', () => {
|
||||||
it('should convert minutes to index correctly', () => {
|
it('should convert minutes to index correctly', () => {
|
||||||
const minutes = 480; // 8:00 AM
|
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);
|
const result = convertMinutesToIndex(minutes);
|
||||||
expect(result).toBe(expectedIndex);
|
expect(result).toBe(expectedIndex);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,19 +21,19 @@ export function useEnforceScheduleLimit(): () => boolean {
|
|||||||
return useCallback(() => {
|
return useCallback(() => {
|
||||||
if (schedules.length >= SCHEDULE_LIMIT) {
|
if (schedules.length >= SCHEDULE_LIMIT) {
|
||||||
showDialog({
|
showDialog({
|
||||||
title: `You have ${SCHEDULE_LIMIT} active schedules!`,
|
title: `You have too many schedules!`,
|
||||||
|
|
||||||
description: (
|
description: (
|
||||||
<>
|
<>
|
||||||
To encourage organization,{' '}
|
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.
|
may have.
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
|
||||||
buttons: close => (
|
buttons: close => (
|
||||||
<Button variant='filled' color='ut-burntorange' onClick={close}>
|
<Button variant='filled' color='ut-burntorange' onClick={close}>
|
||||||
I Understand
|
I understand
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ export interface FlattenedCourseSchedule {
|
|||||||
* @param minutes - The number of minutes.
|
* @param minutes - The number of minutes.
|
||||||
* @returns The index value.
|
* @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.
|
* Get the active schedule, and convert it to be render-able into a calendar.
|
||||||
|
|||||||
46
src/views/hooks/useWhatsNew.tsx
Normal file
46
src/views/hooks/useWhatsNew.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Button } from '@views/components/common/Button';
|
||||||
|
import Text from '@views/components/common/Text/Text';
|
||||||
|
import WhatsNewPopupContent from '@views/components/common/WhatsNewPopup';
|
||||||
|
import { useDialog } from '@views/contexts/DialogContext';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { LogoIcon } from '../components/common/LogoIcon';
|
||||||
|
import useChangelog from './useChangelog';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook that provides a function to display a what's new dialog.
|
||||||
|
*
|
||||||
|
* @returns A function that, when called, shows a dialog with the changelog.
|
||||||
|
*/
|
||||||
|
export default function useWhatsNewPopUp(): () => void {
|
||||||
|
const showDialog = useDialog();
|
||||||
|
const showChangeLog = useChangelog();
|
||||||
|
const { version } = chrome.runtime.getManifest();
|
||||||
|
|
||||||
|
const showPopUp = () => {
|
||||||
|
showDialog(close => ({
|
||||||
|
className: 'w-[830px] flex flex-col items-center gap-spacing-7 p-spacing-8',
|
||||||
|
title: (
|
||||||
|
<div className='flex items-center justify-between gap-4'>
|
||||||
|
<LogoIcon width='48' height='48' />
|
||||||
|
<Text variant='h1' className='text-theme-black'>
|
||||||
|
What's New in UT Registration Plus
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
description: <WhatsNewPopupContent />,
|
||||||
|
buttons: (
|
||||||
|
<div className='flex flex-row items-end gap-spacing-4'>
|
||||||
|
<Button onClick={showChangeLog} variant='minimal' color='ut-black'>
|
||||||
|
Read Changelog v{version}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={close} color='ut-burntorange'>
|
||||||
|
Get started
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return showPopUp;
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import type { CachedData } from '@shared/types/CachedData';
|
|||||||
// Types
|
// Types
|
||||||
type TeamMember = {
|
type TeamMember = {
|
||||||
name: string;
|
name: string;
|
||||||
role: string;
|
role: string[];
|
||||||
githubUsername: string;
|
githubUsername: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -40,21 +40,38 @@ const REPO_NAME = 'UT-Registration-Plus';
|
|||||||
const CONTRIBUTORS_API_ROUTE = `/repos/${REPO_OWNER}/${REPO_NAME}/stats/contributors`;
|
const CONTRIBUTORS_API_ROUTE = `/repos/${REPO_OWNER}/${REPO_NAME}/stats/contributors`;
|
||||||
|
|
||||||
export const LONGHORN_DEVELOPERS_ADMINS = [
|
export const LONGHORN_DEVELOPERS_ADMINS = [
|
||||||
{ name: 'Sriram Hariharan', role: 'Founder', githubUsername: 'sghsri' },
|
{ name: 'Sriram Hariharan', role: ['LHD Co-Founder', 'UTRP Founder'], githubUsername: 'sghsri' },
|
||||||
{ name: 'Elie Soloveichik', role: 'Staff Engineer', githubUsername: 'Razboy20' },
|
{
|
||||||
{ name: 'Diego Perez', role: 'Staff Engineer', githubUsername: 'doprz' },
|
name: 'Elie Soloveichik',
|
||||||
{ name: 'Lukas Zenick', role: 'Senior Software Engineer', githubUsername: 'Lukas-Zenick' },
|
role: ['LHD Co-Founder', 'Learning and Development Director', 'UTRP Senior SWE'],
|
||||||
{ name: 'Isaiah Rodriguez', role: 'Chief Operations and Design Officer', githubUsername: 'IsaDavRod' },
|
githubUsername: 'Razboy20',
|
||||||
{ name: 'Samuel Gunter', role: 'Senior Software Engineer', githubUsername: 'Samathingamajig' },
|
},
|
||||||
{ name: 'Derek Chen', role: 'Senior Software Engineer', githubUsername: 'DereC4' },
|
{
|
||||||
|
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[];
|
] as const satisfies TeamMember[];
|
||||||
|
|
||||||
export const LONGHORN_DEVELOPERS_SWE = [
|
export const LONGHORN_DEVELOPERS_SWE = [
|
||||||
{ name: 'Preston Cook', role: 'Software Engineer', githubUsername: 'Preston-Cook' },
|
{ name: 'Preston Cook', role: ['Software Engineer'], githubUsername: 'Preston-Cook' },
|
||||||
{ name: 'Ethan Lanting', role: 'Software Engineer', githubUsername: 'EthanL06' },
|
{ name: 'Ethan Lanting', role: ['Software Engineer'], githubUsername: 'EthanL06' },
|
||||||
{ name: 'Casey Charleston', role: 'Software Engineer', githubUsername: 'caseycharleston' },
|
{ name: 'Casey Charleston', role: ['Software Engineer'], githubUsername: 'caseycharleston' },
|
||||||
{ name: 'Vinson', role: 'Software Engineer', githubUsername: 'vinsonzheng499' },
|
{ name: 'Lukas Zenick', role: ['LHD Alumni', 'Senior Software Engineer'], githubUsername: 'Lukas-Zenick' },
|
||||||
{ name: 'Vivek', role: 'Software Engineer', githubUsername: 'vivek12311' },
|
{ name: 'Vinson', role: ['LHD Alumni', 'Software Engineer'], githubUsername: 'vinsonzheng499' },
|
||||||
|
{ name: 'Vivek', role: ['LHD Alumni', 'Software Engineer'], githubUsername: 'vivek12311' },
|
||||||
] as const satisfies TeamMember[];
|
] as const satisfies TeamMember[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const SiteSupport = {
|
|||||||
MY_CALENDAR: 'MY_CALENDAR',
|
MY_CALENDAR: 'MY_CALENDAR',
|
||||||
REPORT_ISSUE: 'REPORT_ISSUE',
|
REPORT_ISSUE: 'REPORT_ISSUE',
|
||||||
MY_UT: 'MY_UT',
|
MY_UT: 'MY_UT',
|
||||||
|
COURSE_CATALOG_SEARCH: 'COURSE_CATALOG_SEARCH',
|
||||||
CLASSLIST: 'CLASSLIST',
|
CLASSLIST: 'CLASSLIST',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ export default function getSiteSupport(url: string): SiteSupportType | null {
|
|||||||
if (document.querySelector('#details')) {
|
if (document.querySelector('#details')) {
|
||||||
return SiteSupport.COURSE_CATALOG_DETAILS;
|
return SiteSupport.COURSE_CATALOG_DETAILS;
|
||||||
}
|
}
|
||||||
|
return SiteSupport.COURSE_CATALOG_SEARCH;
|
||||||
}
|
}
|
||||||
if (url.includes('utdirect.utexas.edu') && (url.includes('waitlist') || url.includes('classlist'))) {
|
if (url.includes('utdirect.utexas.edu') && (url.includes('waitlist') || url.includes('classlist'))) {
|
||||||
return SiteSupport.WAITLIST;
|
return SiteSupport.WAITLIST;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default defineConfig({
|
|||||||
],
|
],
|
||||||
shortcuts: {
|
shortcuts: {
|
||||||
focusable: 'outline-none ring-blue-500/50 dark:ring-blue-400/60 ring-0 focus-visible:ring-4',
|
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',
|
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:
|
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',
|
'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',
|
||||||
|
|||||||
Reference in New Issue
Block a user