feat: enable TS strict mode (#168)
* feat: enable TS strict mode * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: colors bug with default * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: text type errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors - add definite assignment assertion * fix: strict TS errors - add definite assignment assertion * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix(ESLint): error on no-explicit-any * fix: type annotations for any types * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors (and remove packages) * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * fix: strict TS errors * feat: enable React.StrictMode * fix: strict TS errors (done!) * fix: build error * fix: replace no-explicit-any assertions * refactor: cleanup * refactor: more cleanup * style: prettier --------- Co-authored-by: Lukas Zenick <lukas@utexas.edu> Co-authored-by: Razboy20 <razboy20@gmail.com>
This commit is contained in:
@@ -9,6 +9,7 @@ module.exports = {
|
||||
ignorePatterns: ['*.html', 'tsconfig.json'],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:storybook/recommended',
|
||||
@@ -164,7 +165,7 @@ module.exports = {
|
||||
],
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/no-unused-vars': 'warn',
|
||||
'@typescript-eslint/naming-convention': 'off',
|
||||
'@typescript-eslint/space-before-function-paren': 'off',
|
||||
|
||||
@@ -41,7 +41,7 @@ This extension, UT Registration Plus (UTRP), tries to streamline most of the unn
|
||||
- Run `pnpm dev`
|
||||
|
||||
> [!NOTE]
|
||||
> Injected content such as extension content on UT pages is not properly styled, and are missing class stylings. When developing for these pages, use `pnpm build -w` to build and watch for changes. This will ensure you are seeing an accurate representation of the extension.
|
||||
> Injected content such as extension content on UT pages is not properly styled, and are missing class stylings. When developing for these pages, use `NODE_ENV='development' pnpm run dev build --mode development -w` to build and watch for changes. This will ensure you are seeing an accurate representation of the extension.
|
||||
|
||||
### Production Builds
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"test:ui": "vitest --ui",
|
||||
"coverage": "vitest run --coverage",
|
||||
"preview": "vite preview",
|
||||
"devtools": "react-devtools",
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
@@ -37,7 +36,6 @@
|
||||
"husky": "^9.0.11",
|
||||
"nanoid": "^5.0.6",
|
||||
"react": "^18.2.0",
|
||||
"react-devtools-core": "^5.0.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"sass": "^1.71.1",
|
||||
"sql.js": "1.10.2"
|
||||
@@ -102,9 +100,8 @@
|
||||
"postcss": "^8.4.35",
|
||||
"prettier": "^3.2.5",
|
||||
"react-dev-utils": "^12.0.1",
|
||||
"react-devtools": "^5.0.0",
|
||||
"storybook": "^7.6.17",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript": "^5.4.3",
|
||||
"unocss": "^0.58.6",
|
||||
"unocss-preset-primitives": "0.0.2-beta.0",
|
||||
"unplugin-icons": "^0.18.5",
|
||||
|
||||
1146
pnpm-lock.yaml
generated
1146
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -4,9 +4,19 @@ import { createRoot } from 'react-dom/client';
|
||||
|
||||
const manifest = chrome.runtime.getManifest();
|
||||
|
||||
/**
|
||||
* Handles editing the storage for a specific area.
|
||||
*
|
||||
* @param {string} areaName - The name of the storage area.
|
||||
* @returns {Function} - A function that accepts changes and sets them in the storage.
|
||||
*/
|
||||
const handleEditStorage = (areaName: 'local' | 'sync' | 'session') => (changes: Record<string, unknown>) => {
|
||||
chrome.storage[areaName].set(changes);
|
||||
};
|
||||
|
||||
interface JSONEditorProps {
|
||||
data: any;
|
||||
onChange: (updates: any) => void;
|
||||
data: unknown;
|
||||
onChange: ReturnType<typeof handleEditStorage>;
|
||||
}
|
||||
|
||||
function JSONEditor(props: JSONEditorProps) {
|
||||
@@ -64,9 +74,9 @@ function JSONEditor(props: JSONEditorProps) {
|
||||
// ));
|
||||
|
||||
function DevDashboard() {
|
||||
const [localStorage, setLocalStorage] = React.useState<any>({});
|
||||
const [syncStorage, setSyncStorage] = React.useState<any>({});
|
||||
const [sessionStorage, setSessionStorage] = React.useState<any>({});
|
||||
const [localStorage, setLocalStorage] = React.useState<Record<string, unknown>>({});
|
||||
const [syncStorage, setSyncStorage] = React.useState<Record<string, unknown>>({});
|
||||
const [sessionStorage, setSessionStorage] = React.useState<Record<string, unknown>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const onVisibilityChange = () => {
|
||||
@@ -95,7 +105,8 @@ function DevDashboard() {
|
||||
// listen for changes to the chrome storage to update the local storage state displayed in the dashboard
|
||||
useEffect(() => {
|
||||
const onChanged = (changes: chrome.storage.StorageChange, areaName: chrome.storage.AreaName) => {
|
||||
let copy = {};
|
||||
let copy: Record<string, unknown> = {};
|
||||
|
||||
if (areaName === 'local') {
|
||||
copy = { ...localStorage };
|
||||
} else if (areaName === 'sync') {
|
||||
@@ -104,8 +115,8 @@ function DevDashboard() {
|
||||
copy = { ...sessionStorage };
|
||||
}
|
||||
|
||||
Object.keys(changes).forEach(key => {
|
||||
copy[key] = changes[key].newValue;
|
||||
Object.keys(changes).forEach((key: string) => {
|
||||
copy[key] = changes[key as keyof typeof changes].newValue;
|
||||
});
|
||||
|
||||
if (areaName === 'local') {
|
||||
@@ -126,10 +137,6 @@ function DevDashboard() {
|
||||
};
|
||||
}, [localStorage, syncStorage, sessionStorage]);
|
||||
|
||||
const handleEditStorage = (areaName: string) => (changes: Record<string, any>) => {
|
||||
chrome.storage[areaName].set(changes);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>
|
||||
@@ -147,4 +154,4 @@ function DevDashboard() {
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(<DevDashboard />);
|
||||
createRoot(document.getElementById('root')!).render(<DevDashboard />);
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
// this is a custom wrapper around react-devtools
|
||||
// that changes it so that we only send messages to the devtools when the current tab is active;
|
||||
import { connectToDevTools } from 'react-devtools-core';
|
||||
|
||||
// connect to the devtools server
|
||||
let ws = new WebSocket('ws://localhost:8097');
|
||||
|
||||
connectToDevTools({
|
||||
websocket: ws,
|
||||
});
|
||||
|
||||
// when the tab's visibile state changes, we connect or disconnect from the devtools
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
ws = new WebSocket('ws://localhost:8097');
|
||||
connectToDevTools({
|
||||
websocket: ws,
|
||||
});
|
||||
} else {
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
@@ -44,7 +44,7 @@ messageListener.listen();
|
||||
|
||||
UserScheduleStore.listen('schedules', async schedules => {
|
||||
const index = await UserScheduleStore.get('activeIndex');
|
||||
const numCourses = schedules[index]?.courses?.length;
|
||||
const numCourses = schedules.newValue[index]?.courses?.length;
|
||||
if (!numCourses) return;
|
||||
|
||||
updateBadgeText(numCourses);
|
||||
|
||||
@@ -11,7 +11,7 @@ const CESHandler: MessageHandler<CESMessage> = {
|
||||
const instructorFirstAndLastName = [instructorFirstName, instructorLastName];
|
||||
chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: (...instructorFirstAndLastName: String[]) => {
|
||||
func: (...instructorFirstAndLastName: string[]) => {
|
||||
const inputElement = document.getElementById(
|
||||
'ctl00_ContentPlaceHolder1_ViewList_tbxValue'
|
||||
) as HTMLInputElement | null;
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import type { TabWithId } from '@background/util/openNewTab';
|
||||
import openNewTab from '@background/util/openNewTab';
|
||||
import { tabs } from '@shared/messages';
|
||||
import type { CalendarBackgroundMessages } from '@shared/messages/CalendarMessages';
|
||||
import type { MessageHandler } from 'chrome-extension-toolkit';
|
||||
|
||||
const getAllTabInfos = async () => {
|
||||
const openTabs = await chrome.tabs.query({});
|
||||
const openTabs = (await chrome.tabs.query({})).filter((tab): tab is TabWithId => tab.id !== undefined);
|
||||
const results = await Promise.allSettled(openTabs.map(tab => tabs.getTabInfo(undefined, tab.id)));
|
||||
|
||||
type TabInfo = PromiseFulfilledResult<Awaited<ReturnType<typeof tabs.getTabInfo>>>;
|
||||
return results
|
||||
.map((result, index) => ({ result, index }))
|
||||
.filter(({ result }) => result.status === 'fulfilled')
|
||||
.map(({ result, index }) => {
|
||||
if (result.status !== 'fulfilled') throw new Error('Will never happen, typescript dumb');
|
||||
return {
|
||||
...result.value,
|
||||
tab: openTabs[index],
|
||||
};
|
||||
});
|
||||
.filter((el): el is { result: TabInfo; index: number } => el.result.status === 'fulfilled')
|
||||
.map(({ result, index }) => ({
|
||||
...result.value,
|
||||
tab: openTabs[index]!,
|
||||
}));
|
||||
};
|
||||
|
||||
const calendarBackgroundHandler: MessageHandler<CalendarBackgroundMessages> = {
|
||||
@@ -25,17 +25,21 @@ const calendarBackgroundHandler: MessageHandler<CalendarBackgroundMessages> = {
|
||||
|
||||
const allTabs = await getAllTabInfos();
|
||||
|
||||
const openCalendarTabInfo = allTabs.find(tab => tab.url.startsWith(calendarUrl));
|
||||
const openCalendarTabInfo = allTabs.find(tab => tab.url?.startsWith(calendarUrl));
|
||||
|
||||
if (openCalendarTabInfo !== undefined) {
|
||||
chrome.tabs.update(openCalendarTabInfo.tab.id, { active: true });
|
||||
if (uniqueId !== undefined) await tabs.openCoursePopup({ uniqueId }, openCalendarTabInfo.tab.id);
|
||||
const tabid = openCalendarTabInfo.tab.id;
|
||||
|
||||
chrome.tabs.update(tabid, { active: true });
|
||||
if (uniqueId !== undefined) await tabs.openCoursePopup({ uniqueId }, tabid);
|
||||
|
||||
sendResponse(openCalendarTabInfo.tab);
|
||||
} else {
|
||||
const urlParams = new URLSearchParams();
|
||||
if (uniqueId !== undefined) urlParams.set('uniqueId', uniqueId.toString());
|
||||
const url = `${calendarUrl}?${urlParams.toString()}`.replace(/\?$/, '');
|
||||
const tab = await openNewTab(url);
|
||||
|
||||
sendResponse(tab);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -12,11 +12,8 @@ export default async function renameSchedule(scheduleId: string, newName: string
|
||||
if (scheduleIndex === -1) {
|
||||
return `Schedule ${scheduleId} does not exist`;
|
||||
}
|
||||
// if (schedules.find(schedule => schedule.name === newName)) {
|
||||
// return `Schedule ${newName} already exists`;
|
||||
// }
|
||||
|
||||
schedules[scheduleIndex].name = newName;
|
||||
schedules[scheduleIndex]!.name = newName;
|
||||
// schedules[scheduleIndex].updatedAt = Date.now();
|
||||
|
||||
await UserScheduleStore.set('schedules', schedules);
|
||||
|
||||
@@ -13,7 +13,8 @@ export default async function switchSchedule(scheduleId: string): Promise<void>
|
||||
if (scheduleIndex === -1) {
|
||||
throw new Error(`Schedule ${scheduleId} does not exist`);
|
||||
}
|
||||
schedules[scheduleIndex].updatedAt = Date.now();
|
||||
|
||||
schedules[scheduleIndex]!.updatedAt = Date.now();
|
||||
|
||||
await UserScheduleStore.set('activeIndex', scheduleIndex);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export async function openDebugTab() {
|
||||
DevStore.get('wasDebugTabVisible'),
|
||||
]);
|
||||
|
||||
const isAlreadyOpen = await (await chrome.tabs.query({})).some(tab => tab.id === debugTabId);
|
||||
const isAlreadyOpen = (await chrome.tabs.query({})).some(tab => tab.id === debugTabId);
|
||||
if (isAlreadyOpen) return;
|
||||
|
||||
const tab = await chrome.tabs.create({
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
export type TabWithId = Omit<chrome.tabs.Tab, 'id'> & { id: number };
|
||||
|
||||
/**
|
||||
* This is a helper function that opens a new tab in the current window, and focuses the window
|
||||
* @param tabIndex - the index of the tab to open the new tab at (optional)
|
||||
* @returns the tab that was opened
|
||||
*/
|
||||
export default async function openNewTab(url: string, tabIndex?: number): Promise<chrome.tabs.Tab> {
|
||||
const tab = await chrome.tabs.create({ url, index: tabIndex, active: true });
|
||||
export default async function openNewTab(url: string, tabIndex?: number): Promise<TabWithId> {
|
||||
const tab = (await chrome.tabs.create({ url, index: tabIndex, active: true })) as TabWithId;
|
||||
await chrome.windows.update(tab.windowId, { focused: true });
|
||||
return tab;
|
||||
}
|
||||
|
||||
@@ -5,4 +5,4 @@ import { createRoot } from 'react-dom/client';
|
||||
|
||||
import CalendarMain from './CalendarMain';
|
||||
|
||||
createRoot(document.getElementById('root')).render(<CalendarMain />);
|
||||
createRoot(document.getElementById('root')!).render(<CalendarMain />);
|
||||
|
||||
@@ -3,4 +3,4 @@ import { createRoot } from 'react-dom/client';
|
||||
|
||||
import App from './App';
|
||||
|
||||
createRoot(document.getElementById('root')).render(<App />);
|
||||
createRoot(document.getElementById('root')!).render(<App />);
|
||||
|
||||
@@ -4,4 +4,4 @@ import PopupMain from '@views/components/PopupMain';
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
createRoot(document.getElementById('root')).render(<PopupMain />);
|
||||
createRoot(document.getElementById('root')!).render(<PopupMain />);
|
||||
|
||||
@@ -43,40 +43,40 @@ export type Semester = {
|
||||
*/
|
||||
export class Course {
|
||||
/** Every course has a uniqueId within UT's registrar system corresponding to each course section */
|
||||
uniqueId: number;
|
||||
uniqueId!: number;
|
||||
/** This is the course number for a course, i.e CS 314 would be 314, MAL 306H would be 306H */
|
||||
number: string;
|
||||
number!: string;
|
||||
/** The full name of the course, i.e. CS 314 Data Structures and Algorithms */
|
||||
fullName: string;
|
||||
fullName!: string;
|
||||
/** Just the english name for a course, without the number and department */
|
||||
courseName: string;
|
||||
courseName!: string;
|
||||
/** The unique identifier for which department that a course belongs to, i.e. CS, MAL, etc. */
|
||||
department: string;
|
||||
department!: string;
|
||||
|
||||
/** The number of credits that a course is worth */
|
||||
creditHours: number;
|
||||
creditHours!: number;
|
||||
/** Is the course open, closed, waitlisted, or cancelled? */
|
||||
status: StatusType;
|
||||
status!: StatusType;
|
||||
/** all the people that are teaching this course, and some metadata about their names */
|
||||
instructors: Instructor[];
|
||||
/** Some courses at UT are reserved for certain groups of people or people within a certain major, which makes it difficult for people outside of that group to register for the course. */
|
||||
isReserved: boolean;
|
||||
isReserved!: boolean;
|
||||
/** The description of the course as an array of "lines". This will include important information as well as a short summary of the topics covered */
|
||||
description?: string[];
|
||||
/** The schedule for the course, which includes the days of the week that the course is taught, the time that the course is taught, and the location that the course is taught */
|
||||
schedule: CourseSchedule;
|
||||
/** the link to the course details page for this course */
|
||||
url: string;
|
||||
url!: string;
|
||||
/** the link to the registration page for this course, for easy access when registering */
|
||||
registerURL?: string;
|
||||
/** At UT, some courses have certain "flags" which aid in graduation */
|
||||
flags: string[];
|
||||
flags!: string[];
|
||||
/** How is the class being taught (online, hybrid, in person, etc) */
|
||||
instructionMode: InstructionMode;
|
||||
instructionMode!: InstructionMode;
|
||||
/** Which semester is the course from */
|
||||
semester: Semester;
|
||||
semester!: Semester;
|
||||
/** Unix timestamp of when the course was last scraped */
|
||||
scrapedAt: number;
|
||||
scrapedAt!: number;
|
||||
/** The colors of the course when displayed */
|
||||
colors: CourseColors;
|
||||
|
||||
|
||||
@@ -30,15 +30,15 @@ export type Location = {
|
||||
*/
|
||||
export class CourseMeeting {
|
||||
/** The day of the week that the course is taught */
|
||||
days: Day[];
|
||||
days!: Day[];
|
||||
/** NOTE: Times starting and after 12 PM have an additional 720 minutes (12 hrs) added to them
|
||||
* The start time of the course, in minutes since midnight
|
||||
* */
|
||||
startTime: number;
|
||||
startTime!: number;
|
||||
/** NOTE: Times starting and after 12 PM have an additional 720 minutes (12 hrs) added to them
|
||||
* The end time of the course, in minutes since midnight
|
||||
* */
|
||||
endTime: number;
|
||||
endTime!: number;
|
||||
/** The location that the course is taught */
|
||||
location?: Location;
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ export class CourseSchedule {
|
||||
if (char === 'S' && nextChar === 'U') {
|
||||
day += nextChar;
|
||||
}
|
||||
return DAY_MAP[day];
|
||||
return DAY_MAP[day as keyof typeof DAY_MAP];
|
||||
})
|
||||
.filter(Boolean) as Day[];
|
||||
|
||||
@@ -47,7 +47,7 @@ export class CourseSchedule {
|
||||
.split('-')
|
||||
.map(time => {
|
||||
const [rawHour, rest] = time.split(':');
|
||||
const [rawMinute, ampm] = rest.split(' ');
|
||||
const [rawMinute, ampm] = rest?.split(' ') ?? ['', ''];
|
||||
const hour = (rawHour === '12' ? 0 : Number(rawHour)) + (ampm === 'pm' ? 12 : 0);
|
||||
const minute = Number(rawMinute);
|
||||
|
||||
@@ -56,17 +56,27 @@ export class CourseSchedule {
|
||||
|
||||
const location = locLine.split(' ').filter(Boolean);
|
||||
|
||||
if (startTime === undefined || endTime === undefined) {
|
||||
throw new Error('Failed to parse time');
|
||||
}
|
||||
|
||||
if (startTime >= endTime) {
|
||||
throw new Error('Start time must be before end time');
|
||||
}
|
||||
|
||||
if (location === undefined) {
|
||||
throw new Error('Failed to parse location');
|
||||
}
|
||||
|
||||
return new CourseMeeting({
|
||||
days,
|
||||
startTime,
|
||||
endTime,
|
||||
location: location.length
|
||||
? {
|
||||
building: location[0],
|
||||
room: location[1],
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
location: {
|
||||
building: location[0] ?? '',
|
||||
room: location[1] ?? '',
|
||||
},
|
||||
} satisfies Serialized<CourseMeeting>);
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to parse schedule: ${dayLine} ${timeLine} ${locLine}`);
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ import type { Serialized } from 'chrome-extension-toolkit';
|
||||
* A type representing an instructor for a course (who teaches it)
|
||||
*/
|
||||
export default class Instructor {
|
||||
fullName: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
fullName?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
middleInitial?: string;
|
||||
|
||||
constructor(instructor: Serialized<Instructor>) {
|
||||
@@ -53,16 +53,16 @@ export default class Instructor {
|
||||
return capitalize(str);
|
||||
};
|
||||
|
||||
if (format === 'abbr') {
|
||||
if (format === 'abbr' && firstName && lastName && firstName[0]) {
|
||||
return `${process(firstName[0])}. ${process(lastName)}`;
|
||||
}
|
||||
if (format === 'full_name') {
|
||||
if (format === 'full_name' && fullName) {
|
||||
return process(fullName);
|
||||
}
|
||||
if (format === 'first_last') {
|
||||
if (format === 'first_last' && firstName && lastName) {
|
||||
return `${process(firstName)} ${process(lastName)}`;
|
||||
}
|
||||
if (format === 'last') {
|
||||
if (format === 'last' && lastName) {
|
||||
return process(lastName);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ export const extendedColors = {
|
||||
} as const;
|
||||
|
||||
type NestedKeys<T> = {
|
||||
[K in keyof T]: T[K] extends Record<string, any> ? `${string & K}-${string & keyof T[K]}` : never;
|
||||
[K in keyof T]: T[K] extends Record<string, unknown> ? `${string & K}-${string & keyof T[K]}` : never;
|
||||
}[keyof T];
|
||||
|
||||
/**
|
||||
@@ -56,6 +56,7 @@ export type ThemeColor = NestedKeys<typeof colors>;
|
||||
export type TWColorway = {
|
||||
[K in keyof typeof theme.colors]: (typeof theme.colors)[K] extends Record<string, unknown> ? K : never;
|
||||
}[keyof typeof theme.colors];
|
||||
export type TWIndex = keyof (typeof theme.colors)[TWColorway];
|
||||
|
||||
/**
|
||||
* Represents the colors for a course.
|
||||
|
||||
@@ -4,7 +4,7 @@ import { theme } from 'unocss/preset-mini';
|
||||
import type { HexColor, Lab, RGB, sRGB } from '../types/Color';
|
||||
import { isHexColor } from '../types/Color';
|
||||
import type { Course } from '../types/Course';
|
||||
import type { CourseColors, TWColorway } from '../types/ThemeColors';
|
||||
import type { CourseColors, TWColorway, TWIndex } from '../types/ThemeColors';
|
||||
import { colorwayIndexes } from '../types/ThemeColors';
|
||||
import type { UserSchedule } from '../types/UserSchedule';
|
||||
|
||||
@@ -14,18 +14,21 @@ import type { UserSchedule } from '../types/UserSchedule';
|
||||
* @param hex - The hexadecimal color value.
|
||||
* @returns An array containing the RGB values.
|
||||
*/
|
||||
export function hexToRGB(hex: HexColor): RGB {
|
||||
export function hexToRGB(hex: HexColor): RGB | undefined {
|
||||
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
|
||||
let shorthandRegex: RegExp = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
|
||||
const parsedHex: string = hex.replace(shorthandRegex, (m, r, g, b) => r + r + g + g + b + b);
|
||||
let shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
|
||||
const parsedHex = hex.replace(shorthandRegex, (m, r, g, b) => r + r + g + g + b + b);
|
||||
|
||||
let result: RegExpExecArray = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(parsedHex);
|
||||
return result ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)] : null;
|
||||
let result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(parsedHex);
|
||||
|
||||
if (!result || !(result.length > 3)) return undefined;
|
||||
|
||||
return [parseInt(result[1]!, 16), parseInt(result[2]!, 16), parseInt(result[3]!, 16)];
|
||||
}
|
||||
|
||||
export const useableColorways = Object.keys(theme.colors)
|
||||
// check that the color is a colorway (is an object)
|
||||
.filter(color => typeof theme.colors[color] === 'object')
|
||||
.filter(color => typeof theme.colors[color as keyof typeof theme.colors] === 'object')
|
||||
.slice(0, 17) as TWColorway[];
|
||||
|
||||
/**
|
||||
@@ -33,12 +36,16 @@ export const useableColorways = Object.keys(theme.colors)
|
||||
* @param bgColor the hex color of the background
|
||||
*/
|
||||
export function pickFontColor(bgColor: HexColor): 'text-white' | 'text-black' | 'text-theme-black' {
|
||||
const coefficients = [0.2126729, 0.7151522, 0.072175];
|
||||
const coefficients = [0.2126729, 0.7151522, 0.072175] as const;
|
||||
|
||||
const flipYs = 0.342; // based on APCA™ 0.98G middle contrast BG color
|
||||
|
||||
const trc = 2.4; // 2.4 exponent for emulating actual monitor perception
|
||||
let Ys = hexToRGB(bgColor).reduce((acc, c, i) => acc + (c / 255.0) ** trc * coefficients[i], 0);
|
||||
const rgb = hexToRGB(bgColor);
|
||||
if (!rgb) throw new Error('bgColor: Invalid hex.');
|
||||
|
||||
// coefficients and rgb are both 3 elements long, so this is safe
|
||||
let Ys = rgb.reduce((acc, c, i) => acc + (c / 255.0) ** trc * coefficients[i]!, 0);
|
||||
|
||||
if (Ys < flipYs) {
|
||||
return 'text-white';
|
||||
@@ -54,13 +61,13 @@ export function pickFontColor(bgColor: HexColor): 'text-white' | 'text-black' |
|
||||
export function getCourseColors(colorway: TWColorway, index?: number, offset: number = 300): CourseColors {
|
||||
if (index === undefined) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
index = colorway in colorwayIndexes ? colorwayIndexes[colorway] : 500;
|
||||
index = colorway in colorwayIndexes ? colorwayIndexes[colorway as keyof typeof colorwayIndexes] : 500;
|
||||
}
|
||||
|
||||
return {
|
||||
primaryColor: theme.colors[colorway][index],
|
||||
secondaryColor: theme.colors[colorway][index + offset],
|
||||
} satisfies CourseColors;
|
||||
primaryColor: theme.colors[colorway][index as TWIndex] as HexColor,
|
||||
secondaryColor: theme.colors[colorway][(index + offset) as TWIndex] as HexColor,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,7 +94,12 @@ export function getColorwayFromColor(color: HexColor): TWColorway {
|
||||
continue;
|
||||
}
|
||||
|
||||
const distance = oklabDistance(rgbToOKlab(hexToRGB(shadeColor)), rgbToOKlab(hexToRGB(color)));
|
||||
const shadeColorRGB = hexToRGB(shadeColor);
|
||||
if (!shadeColorRGB) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const distance = oklabDistance(rgbToOKlab(shadeColorRGB), rgbToOKlab(shadeColorRGB));
|
||||
if (distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
closestColor = shade;
|
||||
@@ -148,7 +160,8 @@ export function getUnusedColor(
|
||||
|
||||
if (sameDepartment.length > 0) {
|
||||
// check to see if any adjacent colorways are available
|
||||
const centerCourse = sameDepartment[Math.floor(Math.random() * sameDepartment.length)];
|
||||
const centerCourse = sameDepartment[Math.floor(Math.random() * sameDepartment.length)]!;
|
||||
|
||||
let nextColorway = getNextColorway(centerCourse.colorway);
|
||||
let prevColorway = getPreviousColorway(centerCourse.colorway);
|
||||
|
||||
@@ -175,11 +188,18 @@ export function getUnusedColor(
|
||||
|
||||
if (shortenedColorways.size > 0) {
|
||||
// TODO: make this go by 3's to leave future spaces open
|
||||
const randomColorway = Array.from(shortenedColorways)[Math.floor(Math.random() * shortenedColorways.size)];
|
||||
const randomColorway = Array.from(shortenedColorways)[Math.floor(Math.random() * shortenedColorways.size)]!;
|
||||
|
||||
return getCourseColors(randomColorway, index, offset);
|
||||
}
|
||||
// no colorways are at least 2 indexes away from any used colors, just get a random colorway
|
||||
const randomColorway = Array.from(availableColorways)[Math.floor(Math.random() * availableColorways.size)];
|
||||
const randomColorway: TWColorway | undefined =
|
||||
Array.from(availableColorways)[Math.floor(Math.random() * availableColorways.size)];
|
||||
|
||||
if (!randomColorway) {
|
||||
throw new Error('randomColorway is undefined');
|
||||
}
|
||||
|
||||
return getCourseColors(randomColorway, index, offset);
|
||||
}
|
||||
// TODO: get just a random color idk
|
||||
|
||||
@@ -10,9 +10,9 @@ import CancelledIcon from '~icons/material-symbols/warning';
|
||||
/**
|
||||
* Get Icon component based on status
|
||||
* @param props.status status
|
||||
* @returns React.ReactElement - the icon component
|
||||
* @returns the icon component
|
||||
*/
|
||||
export function StatusIcon(props: SVGProps<SVGSVGElement> & { status: StatusType }): React.ReactElement {
|
||||
export function StatusIcon(props: SVGProps<SVGSVGElement> & { status: StatusType }): JSX.Element | null {
|
||||
const { status, ...rest } = props;
|
||||
|
||||
switch (props.status) {
|
||||
@@ -23,5 +23,6 @@ export function StatusIcon(props: SVGProps<SVGSVGElement> & { status: StatusType
|
||||
case Status.CANCELLED:
|
||||
return <CancelledIcon {...rest} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,11 @@ import { hexToRGB } from './colors';
|
||||
|
||||
/**
|
||||
* Flattened colors object.
|
||||
* @type {Record<ThemeColor, string>}
|
||||
*/
|
||||
export const colorsFlattened = Object.entries(colors).reduce(
|
||||
(acc, [prefix, group]) => {
|
||||
export const colorsFlattened: Record<ThemeColor, string> = Object.entries(colors).reduce(
|
||||
(acc: Record<ThemeColor, string>, [prefix, group]) => {
|
||||
for (const [name, hex] of Object.entries(group)) {
|
||||
acc[`${prefix}-${name}`] = hex;
|
||||
acc[`${prefix}-${name}` as ThemeColor] = hex;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
@@ -19,9 +18,8 @@ export const colorsFlattened = Object.entries(colors).reduce(
|
||||
|
||||
/**
|
||||
* Represents the flattened RGB values of the colors.
|
||||
* @type {Record<ThemeColor, ReturnType<typeof hexToRgb>>}
|
||||
*/
|
||||
const colorsFlattenedRgb = Object.fromEntries(
|
||||
const colorsFlattenedRgb: Record<ThemeColor, ReturnType<typeof hexToRGB>> = Object.fromEntries(
|
||||
Object.entries(colorsFlattened).map(([name, hex]) => [name, hexToRGB(hex as HexColor)])
|
||||
) as Record<ThemeColor, ReturnType<typeof hexToRGB>>;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { UserSchedule } from '@shared/types/UserSchedule';
|
||||
import { generateRandomId } from '@shared/util/random';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import List from '@views/components/common/List/List';
|
||||
import type { ScheduleDropdownProps } from '@views/components/common/ScheduleDropdown/ScheduleDropdown';
|
||||
import ScheduleDropdown from '@views/components/common/ScheduleDropdown/ScheduleDropdown';
|
||||
import ScheduleListItem from '@views/components/common/ScheduleListItem/ScheduleListItem';
|
||||
import useSchedules, { getActiveSchedule, switchSchedule } from '@views/hooks/useSchedules';
|
||||
@@ -49,7 +50,7 @@ const meta: Meta<typeof ScheduleDropdown> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
render: (args: any) => {
|
||||
render: (args: ScheduleDropdownProps) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const [activeSchedule, schedules] = useSchedules();
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ const generateCourses = (count: number): Course[] => {
|
||||
status: Status.WAITLISTED,
|
||||
uniqueId: 12345 + i, // Make uniqueId different for each course
|
||||
url: 'https://utdirect.utexas.edu/apps/registrar/course_schedule/20242/12345/',
|
||||
colors: tailwindColorways[i],
|
||||
colors: tailwindColorways[i]!,
|
||||
});
|
||||
|
||||
courses.push(course);
|
||||
@@ -86,16 +86,16 @@ const meta = {
|
||||
argTypes: {
|
||||
gap: { control: 'number' },
|
||||
},
|
||||
} satisfies Meta<typeof List>;
|
||||
} satisfies Meta<typeof List<Course>>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
type Story = StoryObj<Meta<typeof List<Course>>>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
draggables: exampleCourses,
|
||||
children: generateCourseBlocks,
|
||||
itemKey: (item: Course) => item.uniqueId,
|
||||
itemKey: item => item.uniqueId,
|
||||
gap: 12,
|
||||
},
|
||||
render: args => (
|
||||
|
||||
@@ -27,7 +27,7 @@ const PromptDialogWithButton = ({ children, ...args }: PromptDialogProps) => {
|
||||
const handleClose = () => setIsOpen(false);
|
||||
const { title, content } = args;
|
||||
|
||||
const childrenWithHandleClose: React.ReactElement[] = children.map(child => {
|
||||
const childrenWithHandleClose: React.ReactElement[] = (children ?? []).map(child => {
|
||||
if (child.type === Button) {
|
||||
return React.cloneElement(child, { onClick: () => handleClose() } as React.HTMLAttributes<HTMLElement>);
|
||||
}
|
||||
|
||||
@@ -87,12 +87,12 @@ export const Default: Story = {
|
||||
courses: [
|
||||
{
|
||||
colors: getCourseColors('pink', 200),
|
||||
courseDeptAndInstr: `${exampleGovCourse.department} ${exampleGovCourse.number} – ${exampleGovCourse.instructors[0].lastName}`,
|
||||
courseDeptAndInstr: `${exampleGovCourse.department} ${exampleGovCourse.number} – ${exampleGovCourse.instructors[0]!.lastName}`,
|
||||
status: exampleGovCourse.status,
|
||||
},
|
||||
{
|
||||
colors: getCourseColors('slate', 500),
|
||||
courseDeptAndInstr: `${examplePsyCourse.department} ${examplePsyCourse.number} – ${examplePsyCourse.instructors[0].lastName}`,
|
||||
courseDeptAndInstr: `${examplePsyCourse.department} ${examplePsyCourse.number} – ${examplePsyCourse.instructors[0]!.lastName}`,
|
||||
status: examplePsyCourse.status,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -16,7 +16,6 @@ const meta = {
|
||||
argTypes: {
|
||||
course: { control: 'object' },
|
||||
meetingIdx: { control: 'number' },
|
||||
rightIcon: { control: 'object' },
|
||||
},
|
||||
} satisfies Meta<typeof CalendarCourse>;
|
||||
export default meta;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Status } from '@shared/types/Course';
|
||||
import { getCourseColors } from '@shared/util/colors';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import type { CalendarCourseCellProps } from '@views/components/calendar/CalendarCourseCell/CalendarCourseCell';
|
||||
import CalendarCourseCell from '@views/components/calendar/CalendarCourseCell/CalendarCourseCell';
|
||||
import React from 'react';
|
||||
|
||||
@@ -20,7 +21,7 @@ const meta = {
|
||||
timeAndLocation: { control: { type: 'text' } },
|
||||
colors: { control: { type: 'object' } },
|
||||
},
|
||||
render: (args: any) => (
|
||||
render: (args: CalendarCourseCellProps) => (
|
||||
<div className='w-45'>
|
||||
<CalendarCourseCell {...args} />
|
||||
</div>
|
||||
@@ -29,7 +30,7 @@ const meta = {
|
||||
courseDeptAndInstr: ExampleCourse.department,
|
||||
className: ExampleCourse.number,
|
||||
status: ExampleCourse.status,
|
||||
timeAndLocation: ExampleCourse.schedule.meetings[0].getTimeString({ separator: '-' }),
|
||||
timeAndLocation: ExampleCourse.schedule.meetings[0]!.getTimeString({ separator: '-' }),
|
||||
|
||||
colors: getCourseColors('emerald', 500),
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@ interface Props {
|
||||
/**
|
||||
* This is the top level react component orchestrating the course catalog page.
|
||||
*/
|
||||
export default function CourseCatalogMain({ support }: Props): JSX.Element {
|
||||
export default function CourseCatalogMain({ support }: Props): JSX.Element | null {
|
||||
const [rows, setRows] = React.useState<ScrapedRow[]>([]);
|
||||
const [selectedCourse, setSelectedCourse] = useState<Course | null>(null);
|
||||
const [showPopup, setShowPopup] = useState(false);
|
||||
@@ -53,8 +53,6 @@ export default function CourseCatalogMain({ support }: Props): JSX.Element {
|
||||
setSelectedCourse(course);
|
||||
};
|
||||
|
||||
// useKeyPress('Escape', handleClearSelectedCourse);
|
||||
|
||||
const [activeSchedule] = useSchedules();
|
||||
|
||||
if (!activeSchedule) {
|
||||
@@ -78,7 +76,7 @@ export default function CourseCatalogMain({ support }: Props): JSX.Element {
|
||||
)
|
||||
)}
|
||||
<CourseCatalogInjectedPopup
|
||||
course={selectedCourse}
|
||||
course={selectedCourse!} // always defined when showPopup is true
|
||||
show={showPopup}
|
||||
onClose={() => setShowPopup(false)}
|
||||
afterLeave={() => setSelectedCourse(null)}
|
||||
|
||||
@@ -20,16 +20,20 @@ import TeamLinks from '../TeamLinks';
|
||||
export default function Calendar(): JSX.Element {
|
||||
const calendarRef = useRef<HTMLDivElement>(null);
|
||||
const { courseCells, activeSchedule } = useFlattenedCourseSchedule();
|
||||
|
||||
const [course, setCourse] = useState<Course | null>((): Course | null => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const uniqueIdRaw = urlParams.get('uniqueId');
|
||||
if (uniqueIdRaw === null) return null;
|
||||
|
||||
const uniqueId = Number(uniqueIdRaw);
|
||||
const course = activeSchedule.courses.find(course => course.uniqueId === uniqueId);
|
||||
if (course === undefined) return null;
|
||||
|
||||
urlParams.delete('uniqueId');
|
||||
const newUrl = `${window.location.pathname}?${urlParams}`.replace(/\?$/, '');
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
|
||||
return course;
|
||||
});
|
||||
|
||||
@@ -41,16 +45,20 @@ export default function Calendar(): JSX.Element {
|
||||
async openCoursePopup({ data, sendResponse }) {
|
||||
const course = activeSchedule.courses.find(course => course.uniqueId === data.uniqueId);
|
||||
if (course === undefined) return;
|
||||
|
||||
setCourse(course);
|
||||
setShowPopup(true);
|
||||
sendResponse(await chrome.tabs.getCurrent());
|
||||
|
||||
const currentTab = await chrome.tabs.getCurrent();
|
||||
if (currentTab === undefined) return;
|
||||
sendResponse(currentTab);
|
||||
},
|
||||
});
|
||||
|
||||
listener.listen();
|
||||
|
||||
return () => listener.unlisten();
|
||||
}, [activeSchedule.courses]);
|
||||
}, [activeSchedule]);
|
||||
|
||||
useEffect(() => {
|
||||
if (course) setShowPopup(true);
|
||||
@@ -85,7 +93,7 @@ export default function Calendar(): JSX.Element {
|
||||
</div>
|
||||
|
||||
<CourseCatalogInjectedPopup
|
||||
course={course}
|
||||
course={course!} // always defined when showPopup is true
|
||||
onClose={() => setShowPopup(false)}
|
||||
open={showPopup}
|
||||
afterLeave={() => setCourse(null)}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Course } from '@shared/types/Course';
|
||||
import type { CourseMeeting } from '@shared/types/CourseMeeting';
|
||||
import React from 'react';
|
||||
|
||||
import styles from './CalendarCourseMeeting.module.scss';
|
||||
@@ -12,28 +11,27 @@ export interface CalendarCourseMeetingProps {
|
||||
course: Course;
|
||||
/* index into course meeting array to display */
|
||||
meetingIdx?: number;
|
||||
/** The icon to display on the right side of the course. This is optional. */
|
||||
rightIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* `CalendarCourseMeeting` is a functional component that displays a course meeting.
|
||||
*
|
||||
* @example
|
||||
* <CalendarCourseMeeting course={course} meeting={meeting} rightIcon={<Icon />} />
|
||||
* <CalendarCourseMeeting course={course} meeting={meeting} />
|
||||
*/
|
||||
export default function CalendarCourseMeeting({
|
||||
course,
|
||||
meetingIdx,
|
||||
rightIcon,
|
||||
}: CalendarCourseMeetingProps): JSX.Element {
|
||||
let meeting: CourseMeeting | null = meetingIdx !== undefined ? course.schedule.meetings[meetingIdx] : null;
|
||||
export default function CalendarCourseMeeting({ course, meetingIdx }: CalendarCourseMeetingProps): JSX.Element | null {
|
||||
let meeting = meetingIdx !== undefined ? course.schedule.meetings[meetingIdx] : undefined;
|
||||
|
||||
if (!meeting) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.component}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles['course-detail']}>
|
||||
<div className={styles.course}>
|
||||
{course.department} {course.number} - {course.instructors[0].lastName}
|
||||
{course.department} {course.number} - {course.instructors[0]?.lastName}
|
||||
</div>
|
||||
<div className={styles['time-and-location']}>
|
||||
{`${meeting.getTimeString({ separator: '-', capitalize: true })}${
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ThemeColor, TWIndex } from '@shared/types/ThemeColors';
|
||||
import { getThemeColorHexByName } from '@shared/util/themeColors';
|
||||
import Divider from '@views/components/common/Divider/Divider';
|
||||
import React from 'react';
|
||||
@@ -30,16 +31,19 @@ const baseColors = [
|
||||
'fuchsia',
|
||||
'pink',
|
||||
'rose',
|
||||
];
|
||||
] as const;
|
||||
|
||||
const BaseColorNum = 500;
|
||||
const StartingShadeIndex = 200;
|
||||
const BaseColorNum: TWIndex = 500;
|
||||
const StartingShadeIndex: TWIndex = 200;
|
||||
const ShadeIncrement = 100;
|
||||
|
||||
const colorPatchColors = new Map<string, string[]>(
|
||||
baseColors.map((baseColor: string) => [
|
||||
baseColors.map(baseColor => [
|
||||
theme.colors[baseColor][BaseColorNum],
|
||||
Array.from({ length: 6 }, (_, index) => theme.colors[baseColor][StartingShadeIndex + ShadeIncrement * index]),
|
||||
Array.from(
|
||||
{ length: 6 },
|
||||
(_, index) => theme.colors[baseColor][(StartingShadeIndex + ShadeIncrement * index) as TWIndex]
|
||||
),
|
||||
])
|
||||
);
|
||||
|
||||
@@ -51,7 +55,7 @@ const hexCodeToBaseColor = new Map<string, string>(
|
||||
* Props for the CourseCellColorPicker component.
|
||||
*/
|
||||
export interface CourseCellColorPickerProps {
|
||||
setSelectedColor: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
setSelectedColor: React.Dispatch<React.SetStateAction<ThemeColor | null>>;
|
||||
isInvertColorsToggled: boolean;
|
||||
setIsInvertColorsToggled: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
@@ -89,7 +93,7 @@ export default function CourseCellColorPicker({
|
||||
const [hexCode, setHexCode] = React.useState<string>(
|
||||
getThemeColorHexByName('ut-gray').slice(1).toLocaleLowerCase()
|
||||
);
|
||||
const hexCodeWithHash = `#${hexCode}`;
|
||||
const hexCodeWithHash = `#${hexCode}` as ThemeColor;
|
||||
const selectedBaseColor = hexCodeToBaseColor.get(hexCodeWithHash);
|
||||
|
||||
const handleSelectColorPatch = (baseColor: string) => {
|
||||
@@ -124,7 +128,7 @@ export default function CourseCellColorPicker({
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{hexCodeToBaseColor.has(hexCodeWithHash) && (
|
||||
{selectedBaseColor && (
|
||||
<>
|
||||
<Divider orientation='horizontal' size='100%' className='my-1' />
|
||||
<div className='grid grid-cols-6 gap-1'>
|
||||
|
||||
@@ -26,7 +26,7 @@ function CalendarHour({ hour }: { hour: number }) {
|
||||
}
|
||||
|
||||
function makeGridRow(row: number, cols: number): JSX.Element {
|
||||
const hour = hoursOfDay[row];
|
||||
const hour = hoursOfDay[row]!;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -83,14 +83,17 @@ interface AccountForCourseConflictsProps {
|
||||
// TODO: Deal with react strict mode (wacky movements)
|
||||
function AccountForCourseConflicts({ courseCells, setCourse }: AccountForCourseConflictsProps): JSX.Element[] {
|
||||
// Groups by dayIndex to identify overlaps
|
||||
const days = courseCells.reduce((acc, cell: CalendarGridCourse) => {
|
||||
const { dayIndex } = cell.calendarGridPoint;
|
||||
if (!acc[dayIndex]) {
|
||||
acc[dayIndex] = [];
|
||||
}
|
||||
acc[dayIndex].push(cell);
|
||||
return acc;
|
||||
}, {});
|
||||
const days = courseCells.reduce(
|
||||
(acc, cell: CalendarGridCourse) => {
|
||||
const { dayIndex } = cell.calendarGridPoint;
|
||||
if (acc[dayIndex] === undefined) {
|
||||
acc[dayIndex] = [];
|
||||
}
|
||||
acc[dayIndex]!.push(cell);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<number, CalendarGridCourse[]>
|
||||
);
|
||||
|
||||
// Check for overlaps within each day and adjust gridColumnIndex and totalColumns
|
||||
Object.values(days).forEach((dayCells: CalendarGridCourse[]) => {
|
||||
@@ -121,7 +124,7 @@ function AccountForCourseConflicts({ courseCells, setCourse }: AccountForCourseC
|
||||
});
|
||||
|
||||
return courseCells.map((block, i) => {
|
||||
const { courseDeptAndInstr, timeAndLocation, status } = courseCells[i].componentProps;
|
||||
const { courseDeptAndInstr, timeAndLocation, status } = courseCells[i]!.componentProps;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -129,8 +132,8 @@ function AccountForCourseConflicts({ courseCells, setCourse }: AccountForCourseC
|
||||
style={{
|
||||
gridColumn: `${block.calendarGridPoint.dayIndex + 3}`,
|
||||
gridRow: `${block.calendarGridPoint.startIndex} / ${block.calendarGridPoint.endIndex}`,
|
||||
width: `calc(100% / ${block.totalColumns})`,
|
||||
marginLeft: `calc(100% * ${(block.gridColumnStart - 1) / block.totalColumns})`,
|
||||
width: `calc(100% / ${block.totalColumns ?? 1})`,
|
||||
marginLeft: `calc(100% * ${((block.gridColumnStart ?? 0) - 1) / (block.totalColumns ?? 1)})`,
|
||||
padding: '0px 10px 4px 0px',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
||||
import type { UserSchedule } from '@shared/types/UserSchedule';
|
||||
import type { Serialized } from 'chrome-extension-toolkit';
|
||||
import { toPng } from 'html-to-image';
|
||||
|
||||
export const CAL_MAP = {
|
||||
@@ -13,9 +15,9 @@ export const CAL_MAP = {
|
||||
|
||||
/**
|
||||
* Retrieves the schedule from the UserScheduleStore based on the active index.
|
||||
* @returns {Promise<any>} A promise that resolves to the retrieved schedule.
|
||||
* @returns A promise that resolves to the retrieved schedule.
|
||||
*/
|
||||
const getSchedule = async () => {
|
||||
const getSchedule = async (): Promise<Serialized<UserSchedule> | undefined> => {
|
||||
const schedules = await UserScheduleStore.get('schedules');
|
||||
const activeIndex = await UserScheduleStore.get('activeIndex');
|
||||
const schedule = schedules[activeIndex];
|
||||
@@ -61,6 +63,10 @@ export const saveAsCal = async () => {
|
||||
|
||||
let icsString = 'BEGIN:VCALENDAR\nVERSION:2.0\nCALSCALE:GREGORIAN\nX-WR-CALNAME:My Schedule\n';
|
||||
|
||||
if (!schedule) {
|
||||
throw new Error('No schedule found');
|
||||
}
|
||||
|
||||
schedule.courses.forEach(course => {
|
||||
course.schedule.meetings.forEach(meeting => {
|
||||
const { startTime, endTime, days, location } = meeting;
|
||||
@@ -85,7 +91,7 @@ export const saveAsCal = async () => {
|
||||
icsString += `DTEND:${endDate}\n`;
|
||||
icsString += `RRULE:FREQ=WEEKLY;BYDAY=${icsDays}\n`;
|
||||
icsString += `SUMMARY:${course.fullName}\n`;
|
||||
icsString += `LOCATION:${location.building} ${location.room}\n`;
|
||||
icsString += `LOCATION:${location?.building ?? ''} ${location?.room ?? ''}\n`;
|
||||
icsString += `END:VEVENT\n`;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,7 +63,7 @@ export function Button({
|
||||
disabled={disabled}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
>
|
||||
{icon && <Icon className='h-6 w-6' />}
|
||||
{Icon && <Icon className='h-6 w-6' />}
|
||||
{!isIconOnly && (
|
||||
<Text variant='h4' className='translate-y-0.08'>
|
||||
{children}
|
||||
|
||||
@@ -9,7 +9,7 @@ import styles from './Card.module.scss';
|
||||
export type Props = {
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
onClick?: (...args) => void;
|
||||
onClick?: (...args: unknown[]) => void;
|
||||
children?: React.ReactNode;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
@@ -31,7 +31,7 @@ export function Chip({ label }: React.PropsWithChildren<Props>): JSX.Element {
|
||||
style={{
|
||||
backgroundColor: '#FFD600',
|
||||
}}
|
||||
title={Object.keys(flagMap).find(key => flagMap[key] === label)}
|
||||
title={Object.entries(flagMap).find(([full, short]) => short === label)![0]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
|
||||
@@ -6,7 +6,7 @@ import React, { Fragment } from 'react';
|
||||
|
||||
import ExtensionRoot from '../ExtensionRoot/ExtensionRoot';
|
||||
|
||||
interface _DialogProps {
|
||||
export interface _DialogProps {
|
||||
className?: string;
|
||||
title?: JSX.Element;
|
||||
description?: JSX.Element;
|
||||
@@ -21,7 +21,7 @@ export type DialogProps = _DialogProps & Omit<TransitionRootProps<typeof HDialog
|
||||
* A reusable popup component that can be used to display content on the page
|
||||
*/
|
||||
export default function Dialog(props: PropsWithChildren<DialogProps>): JSX.Element {
|
||||
const { children, className, open, onTransitionEnd, ...rest } = props;
|
||||
const { children, className, open, ...rest } = props;
|
||||
|
||||
return (
|
||||
<Transition show={open} as={HDialog} {...rest}>
|
||||
|
||||
@@ -34,8 +34,10 @@ export default function ExtensionRoot(props: React.PropsWithChildren<Props>): JS
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.extensionRoot, props.className)} data-testid={props.testId}>
|
||||
{props.children}
|
||||
</div>
|
||||
<React.StrictMode>
|
||||
<div className={clsx(styles.extensionRoot, props.className)} data-testid={props.testId}>
|
||||
{props.children}
|
||||
</div>
|
||||
</React.StrictMode>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
.link {
|
||||
font-family: 'Inter', sans-serif;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
cursor: not-allowed !important;
|
||||
text-decoration: none !important;
|
||||
color: #999999 !important;
|
||||
}
|
||||
@@ -5,8 +5,6 @@ import clsx from 'clsx';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import styles from './Link.module.scss';
|
||||
|
||||
type Props = TextProps<'a'> & {
|
||||
href?: string;
|
||||
disabled?: boolean;
|
||||
@@ -19,7 +17,10 @@ export default function Link(props: PropsWithChildren<Props>): JSX.Element {
|
||||
let { className, href, ...passedProps } = props;
|
||||
|
||||
if (href && !props.onClick) {
|
||||
passedProps.onClick = () => background.openNewTab({ url: href });
|
||||
passedProps.onClick = e => {
|
||||
e.preventDefault();
|
||||
background.openNewTab({ url: href });
|
||||
};
|
||||
}
|
||||
const isDisabled = props.disabled || (!href && !props.onClick);
|
||||
|
||||
@@ -28,11 +29,13 @@ export default function Link(props: PropsWithChildren<Props>): JSX.Element {
|
||||
color='bluebonnet'
|
||||
{...passedProps}
|
||||
as='a'
|
||||
aria-disabled={isDisabled}
|
||||
href={!isDisabled ? href : undefined}
|
||||
tabIndex={isDisabled ? -1 : 0}
|
||||
className={clsx(
|
||||
styles.link,
|
||||
{
|
||||
[styles.disabled]: isDisabled,
|
||||
'underline cursor-pointer': !isDisabled,
|
||||
'cursor-not-allowed color-ut-gray': isDisabled,
|
||||
},
|
||||
props.className
|
||||
)}
|
||||
|
||||
@@ -31,7 +31,10 @@ function reorder<T>(list: T[], startIndex: number, endIndex: number) {
|
||||
const listCopy = [...list];
|
||||
|
||||
const [removed] = listCopy.splice(startIndex, 1);
|
||||
listCopy.splice(endIndex, 0, removed);
|
||||
if (removed) {
|
||||
listCopy.splice(endIndex, 0, removed);
|
||||
}
|
||||
|
||||
return listCopy;
|
||||
}
|
||||
|
||||
@@ -78,7 +81,7 @@ function List<T>({ draggables, itemKey, children, onReordered, gap }: ListProps<
|
||||
// check if the draggables content has *actually* changed
|
||||
if (
|
||||
draggables.length === items.length &&
|
||||
draggables.every((element, index) => itemKey(element) === items[index].id)
|
||||
draggables.every((element, index) => itemKey(element) === items[index]?.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -86,12 +89,12 @@ function List<T>({ draggables, itemKey, children, onReordered, gap }: ListProps<
|
||||
}, [draggables, itemKey, items]);
|
||||
|
||||
const onDragEnd: OnDragEndResponder = useCallback(
|
||||
result => {
|
||||
if (!result.destination) return;
|
||||
if (result.source.index === result.destination.index) return;
|
||||
({ destination, source }) => {
|
||||
if (!destination) return;
|
||||
if (source.index === destination.index) return;
|
||||
|
||||
// will reorder in place
|
||||
const reordered = reorder(items, result.source.index, result.destination.index);
|
||||
const reordered = reorder(items, source.index, destination.index);
|
||||
|
||||
setItems(reordered);
|
||||
onReordered(reordered.map(item => item.content));
|
||||
@@ -125,7 +128,7 @@ function List<T>({ draggables, itemKey, children, onReordered, gap }: ListProps<
|
||||
}}
|
||||
>
|
||||
<ExtensionRoot>
|
||||
{transformFunction(items[rubric.source.index].content, provided.dragHandleProps)}
|
||||
{transformFunction(items[rubric.source.index]!.content, provided.dragHandleProps!)}
|
||||
</ExtensionRoot>
|
||||
</Item>
|
||||
);
|
||||
@@ -135,17 +138,22 @@ function List<T>({ draggables, itemKey, children, onReordered, gap }: ListProps<
|
||||
<div {...provided.droppableProps} ref={provided.innerRef} style={{ marginBottom: `-${gap}px` }}>
|
||||
{items.map((item, index) => (
|
||||
<Draggable key={item.id} draggableId={item.id.toString()} index={index}>
|
||||
{draggableProvided => (
|
||||
{({ innerRef, draggableProps, dragHandleProps }) => (
|
||||
<div
|
||||
ref={draggableProvided.innerRef}
|
||||
{...draggableProvided.draggableProps}
|
||||
ref={innerRef}
|
||||
{...draggableProps}
|
||||
style={{
|
||||
...draggableProvided.draggableProps.style,
|
||||
...draggableProps.style,
|
||||
// if last item, don't add margin
|
||||
marginBottom: `${gap}px`,
|
||||
}}
|
||||
>
|
||||
{transformFunction(item.content, draggableProvided.dragHandleProps)}
|
||||
{
|
||||
transformFunction(
|
||||
item.content,
|
||||
dragHandleProps!
|
||||
) /* always exists; only doesn't when "isDragDisabled" is set */
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DraggableProvidedDragHandleProps } from '@hello-pangea/dnd';
|
||||
import { background } from '@shared/messages';
|
||||
import type { Course } from '@shared/types/Course';
|
||||
import { Status } from '@shared/types/Course';
|
||||
@@ -17,7 +18,7 @@ export interface PopupCourseBlockProps {
|
||||
className?: string;
|
||||
course: Course;
|
||||
colors: CourseColors;
|
||||
dragHandleProps?: any;
|
||||
dragHandleProps?: DraggableProvidedDragHandleProps;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,7 @@ import DropdownArrowUp from '~icons/material-symbols/arrow-drop-up';
|
||||
/**
|
||||
* Props for the Dropdown component.
|
||||
*/
|
||||
export type Props = {
|
||||
export type ScheduleDropdownProps = {
|
||||
defaultOpen?: boolean;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
@@ -17,7 +17,7 @@ export type Props = {
|
||||
/**
|
||||
* This is a reusable dropdown component that can be used to toggle the visiblity of information
|
||||
*/
|
||||
export default function ScheduleDropdown(props: Props) {
|
||||
export default function ScheduleDropdown(props: ScheduleDropdownProps) {
|
||||
const [activeSchedule] = useSchedules();
|
||||
|
||||
return (
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function ScheduleListItem({ schedule, dragHandleProps, onClick }:
|
||||
<div className='group flex flex-1 items-center overflow-x-hidden'>
|
||||
<div
|
||||
className='flex flex-grow items-center gap-1.5 overflow-x-hidden'
|
||||
onClick={(...e) => !isEditing && onClick(...e)}
|
||||
onClick={(...e) => !isEditing && onClick?.(...e)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
|
||||
@@ -16,7 +16,9 @@ type OurProps<TTag extends ReactTag> = {
|
||||
ref?: React.ForwardedRef<React.ElementRef<TTag>>;
|
||||
};
|
||||
|
||||
type AsProps<TTag extends ReactTag, TOverrides = {}> = CleanProps<TTag, keyof TOverrides> & OurProps<TTag> & TOverrides;
|
||||
type AsProps<TTag extends ReactTag, TOverrides = object> = CleanProps<TTag, keyof TOverrides> &
|
||||
OurProps<TTag> &
|
||||
TOverrides;
|
||||
|
||||
const variants = ['mini', 'small', 'p', 'h4', 'h3-course', 'h3', 'h2-course', 'h2', 'h1-course', 'h1'] as const;
|
||||
|
||||
@@ -25,14 +27,15 @@ type Variant = (typeof variants)[number];
|
||||
/**
|
||||
* Props for the Text component.
|
||||
*/
|
||||
export type TextProps<TTag extends ElementType = 'span'> = PropsOf<TTag>['className'] extends string
|
||||
? AsProps<
|
||||
TTag,
|
||||
{
|
||||
variant?: Variant;
|
||||
}
|
||||
>
|
||||
: never;
|
||||
export type TextProps<TTag extends ElementType = 'span'> =
|
||||
NonNullable<PropsOf<TTag>['className']> extends string
|
||||
? AsProps<
|
||||
TTag,
|
||||
{
|
||||
variant?: Variant;
|
||||
}
|
||||
>
|
||||
: never;
|
||||
|
||||
/**
|
||||
* A reusable Text component with props that build on top of the design system for the extension
|
||||
@@ -47,4 +50,4 @@ function Text<TTag extends ElementType = 'span'>(
|
||||
return <Comp className={mergedClassName} {...rest} ref={ref} />;
|
||||
}
|
||||
|
||||
export default React.forwardRef(Text) as typeof Text;
|
||||
export default React.forwardRef(Text) as <TTag extends ElementType = 'span'>(props: TextProps<TTag>) => JSX.Element;
|
||||
|
||||
@@ -21,7 +21,7 @@ type Props = {
|
||||
* This component is responsible for loading the next page of courses when the user scrolls to the bottom of the page.
|
||||
* @returns
|
||||
*/
|
||||
export default function AutoLoad({ addRows }: Props): JSX.Element {
|
||||
export default function AutoLoad({ addRows }: Props): JSX.Element | null {
|
||||
const [container, setContainer] = useState<HTMLDivElement | null>(null);
|
||||
const [status, setStatus] = useState<AutoLoadStatusType>(AutoLoadStatus.IDLE);
|
||||
|
||||
|
||||
@@ -53,40 +53,41 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
|
||||
const [status, setStatus] = React.useState<DataStatusType>(DataStatus.LOADING);
|
||||
const ref = React.useRef<HighchartsReact.RefObject>(null);
|
||||
|
||||
const chartData = React.useMemo(() => {
|
||||
if (status === DataStatus.FOUND && distributions[semester]) {
|
||||
return Object.entries(distributions[semester]).map(([grade, count]) => ({
|
||||
y: count,
|
||||
color: GRADE_COLORS[grade as LetterGrade],
|
||||
}));
|
||||
}
|
||||
return Array(12).fill(0);
|
||||
}, [distributions, semester, status]);
|
||||
// const chartData = React.useMemo(() => {
|
||||
// if (status === DataStatus.FOUND && distributions[semester]) {
|
||||
// return Object.entries(distributions[semester]).map(([grade, count]) => ({
|
||||
// y: count,
|
||||
// color: GRADE_COLORS[grade as LetterGrade],
|
||||
// }));
|
||||
// }
|
||||
// return Array(12).fill(0);
|
||||
// }, [distributions, semester, status]);
|
||||
// const chartData: unknown[] = [];
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchInitialData = async () => {
|
||||
try {
|
||||
const [aggregateDist, semesters] = await queryAggregateDistribution(course);
|
||||
const initialDistributions: Record<string, Distribution> = { Aggregate: aggregateDist };
|
||||
const semesterPromises = semesters.map(semester => querySemesterDistribution(course, semester));
|
||||
const semesterDistributions = await Promise.all(semesterPromises);
|
||||
semesters.forEach((semester, i) => {
|
||||
initialDistributions[`${semester.season} ${semester.year}`] = semesterDistributions[i];
|
||||
});
|
||||
setDistributions(initialDistributions);
|
||||
setStatus(DataStatus.FOUND);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (e instanceof NoDataError) {
|
||||
setStatus(DataStatus.NOT_FOUND);
|
||||
} else {
|
||||
setStatus(DataStatus.ERROR);
|
||||
}
|
||||
}
|
||||
};
|
||||
// React.useEffect(() => {
|
||||
// const fetchInitialData = async () => {
|
||||
// try {
|
||||
// const [aggregateDist, semesters] = await queryAggregateDistribution(course);
|
||||
// const initialDistributions: Record<string, Distribution> = { Aggregate: aggregateDist };
|
||||
// const semesterPromises = semesters.map(semester => querySemesterDistribution(course, semester));
|
||||
// const semesterDistributions = await Promise.all(semesterPromises);
|
||||
// semesters.forEach((semester, i) => {
|
||||
// initialDistributions[`${semester.season} ${semester.year}`] = semesterDistributions[i];
|
||||
// });
|
||||
// setDistributions(initialDistributions);
|
||||
// setStatus(DataStatus.FOUND);
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// if (e instanceof NoDataError) {
|
||||
// setStatus(DataStatus.NOT_FOUND);
|
||||
// } else {
|
||||
// setStatus(DataStatus.ERROR);
|
||||
// }
|
||||
// }
|
||||
// };
|
||||
|
||||
fetchInitialData();
|
||||
}, [course]);
|
||||
// fetchInitialData();
|
||||
// }, [course]);
|
||||
|
||||
const handleSelectSemester = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setSemester(event.target.value);
|
||||
@@ -129,7 +130,7 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
|
||||
{
|
||||
type: 'column',
|
||||
name: 'Grades',
|
||||
data: chartData,
|
||||
// data: chartData,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -156,7 +157,7 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
|
||||
<>
|
||||
<div className='w-full flex items-center justify-center gap-[12px]'>
|
||||
<Text variant='p'>Grade distribution for {`${course.department} ${course.number}`}</Text>
|
||||
<select
|
||||
{/* <select
|
||||
className='border border rounded-[4px] border-solid px-[12px] py-[8px]'
|
||||
onChange={handleSelectSemester}
|
||||
>
|
||||
@@ -180,7 +181,7 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
|
||||
{semester}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</select> */}
|
||||
</div>
|
||||
<HighchartsReact ref={ref} highcharts={Highcharts} options={chartOptions} />
|
||||
</>
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function HeadingAndActions({ course, activeSchedule, onClose }: H
|
||||
const formattedUniqueId = uniqueId.toString().padStart(5, '0');
|
||||
|
||||
const getInstructorFullName = (instructor: Instructor) => {
|
||||
const { firstName, lastName } = instructor;
|
||||
const { firstName = '', lastName = '' } = instructor;
|
||||
if (firstName === '') return capitalizeString(lastName);
|
||||
return `${capitalizeString(firstName)} ${capitalizeString(lastName)}`;
|
||||
};
|
||||
@@ -76,7 +76,7 @@ export default function HeadingAndActions({ course, activeSchedule, onClose }: H
|
||||
|
||||
const handleOpenCES = async () => {
|
||||
const openTabs = instructors.map(instructor => {
|
||||
let { firstName, lastName } = instructor;
|
||||
let { firstName = '', lastName = '' } = instructor;
|
||||
firstName = capitalizeString(firstName);
|
||||
lastName = capitalizeString(lastName);
|
||||
return openCESPage({ instructorFirstName: firstName, instructorLastName: lastName });
|
||||
@@ -134,9 +134,12 @@ export default function HeadingAndActions({ course, activeSchedule, onClose }: H
|
||||
.flatMap((el, i) => (i === 0 ? [el] : [', ', el]))}
|
||||
</Text>
|
||||
)}
|
||||
<div className='flex gap-1'>
|
||||
{flags.map(flag => (
|
||||
<Chip key={flagMap[flag]} label={flagMap[flag]} />
|
||||
<div className='flex items-center gap-1'>
|
||||
{flags.map((flag: string) => (
|
||||
<Chip
|
||||
key={flagMap[flag as keyof typeof flagMap]}
|
||||
label={flagMap[flag as keyof typeof flagMap]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -145,7 +148,11 @@ export default function HeadingAndActions({ course, activeSchedule, onClose }: H
|
||||
const daysString = meeting.getDaysString({ format: 'long', separator: 'long' });
|
||||
const timeString = meeting.getTimeString({ separator: ' to ', capitalize: false });
|
||||
return (
|
||||
<Text key={daysString + timeString + meeting.location.building} variant='h4' as='p'>
|
||||
<Text
|
||||
key={daysString + timeString + (meeting.location?.building ?? '')}
|
||||
variant='h4'
|
||||
as='p'
|
||||
>
|
||||
{daysString} {timeString}
|
||||
{meeting.location && (
|
||||
<>
|
||||
|
||||
@@ -14,7 +14,7 @@ const RECRUIT_FROM_DEPARTMENTS = ['C S', 'ECE', 'MIS', 'CSE', 'EE', 'ITD'];
|
||||
* This adds a new column to the course catalog table header.
|
||||
* @returns a react portal to the new column or null if the column has not been created yet.
|
||||
*/
|
||||
export default function RecruitmentBanner(): JSX.Element {
|
||||
export default function RecruitmentBanner(): JSX.Element | null {
|
||||
const [container, setContainer] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import styles from './TableRow.module.scss';
|
||||
interface Props {
|
||||
isSelected: boolean;
|
||||
row: ScrapedRow;
|
||||
onClick: (...args: any[]) => any;
|
||||
onClick: (...args: unknown[]) => unknown;
|
||||
activeSchedule?: UserSchedule;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function TableRow({ row, isSelected, activeSchedule, onClick }: P
|
||||
const { element, course } = row;
|
||||
|
||||
useEffect(() => {
|
||||
element.classList.add(styles.row);
|
||||
element.classList.add(styles.row!);
|
||||
element.classList.add('group');
|
||||
const portalContainer = document.createElement('td');
|
||||
// portalContainer.style.textAlign = 'right';
|
||||
@@ -39,12 +39,12 @@ export default function TableRow({ row, isSelected, activeSchedule, onClick }: P
|
||||
|
||||
return () => {
|
||||
portalContainer.remove();
|
||||
element.classList.remove(styles.row);
|
||||
element.classList.remove(styles.row!);
|
||||
};
|
||||
}, [element]);
|
||||
|
||||
useEffect(() => {
|
||||
element.classList[isSelected ? 'add' : 'remove'](styles.selectedRow);
|
||||
element.classList[isSelected ? 'add' : 'remove'](styles.selectedRow!);
|
||||
}, [isSelected, element.classList]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -52,10 +52,10 @@ export default function TableRow({ row, isSelected, activeSchedule, onClick }: P
|
||||
|
||||
const isInSchedule = activeSchedule.containsCourse(course);
|
||||
|
||||
element.classList[isInSchedule ? 'add' : 'remove'](styles.inActiveSchedule);
|
||||
element.classList[isInSchedule ? 'add' : 'remove'](styles.inActiveSchedule!);
|
||||
|
||||
return () => {
|
||||
element.classList.remove(styles.inActiveSchedule);
|
||||
element.classList.remove(styles.inActiveSchedule!);
|
||||
};
|
||||
}, [activeSchedule, course, element.classList]);
|
||||
|
||||
@@ -72,11 +72,11 @@ export default function TableRow({ row, isSelected, activeSchedule, onClick }: P
|
||||
}
|
||||
}
|
||||
|
||||
element.classList[conflicts.length ? 'add' : 'remove'](styles.isConflict);
|
||||
element.classList[conflicts.length ? 'add' : 'remove'](styles.isConflict!);
|
||||
setConflicts(conflicts);
|
||||
|
||||
return () => {
|
||||
element.classList.remove(styles.isConflict);
|
||||
element.classList.remove(styles.isConflict!);
|
||||
setConflicts([]);
|
||||
};
|
||||
}, [activeSchedule, course, element.classList]);
|
||||
|
||||
@@ -15,10 +15,10 @@ export default function TableSubheading({ row }: Props): JSX.Element | null {
|
||||
const { element } = row;
|
||||
|
||||
useEffect(() => {
|
||||
element.classList.add(styles.subheader);
|
||||
element.classList.add(styles.subheader!);
|
||||
|
||||
return () => {
|
||||
element.classList.remove(styles.subheader);
|
||||
element.classList.remove(styles.subheader!);
|
||||
};
|
||||
}, [element]);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { HexColor } from '@shared/types/Color';
|
||||
import type { Course, StatusType } from '@shared/types/Course';
|
||||
import type { CourseMeeting } from '@shared/types/CourseMeeting';
|
||||
import { colors } from '@shared/types/ThemeColors';
|
||||
import { UserSchedule } from '@shared/types/UserSchedule';
|
||||
import type { UserSchedule } from '@shared/types/UserSchedule';
|
||||
import type { CalendarCourseCellProps } from '@views/components/calendar/CalendarCourseCell/CalendarCourseCell';
|
||||
|
||||
import useSchedules from './useSchedules';
|
||||
@@ -13,6 +13,8 @@ const dayToNumber = {
|
||||
Wednesday: 2,
|
||||
Thursday: 3,
|
||||
Friday: 4,
|
||||
Saturday: 5,
|
||||
Sunday: 6,
|
||||
} as const satisfies Record<string, number>;
|
||||
|
||||
interface CalendarGridPoint {
|
||||
@@ -42,7 +44,7 @@ export interface CalendarGridCourse {
|
||||
*/
|
||||
export interface FlattenedCourseSchedule {
|
||||
courseCells: CalendarGridCourse[];
|
||||
activeSchedule?: UserSchedule;
|
||||
activeSchedule: UserSchedule;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,46 +61,22 @@ export const convertMinutesToIndex = (minutes: number): number => Math.floor((mi
|
||||
export function useFlattenedCourseSchedule(): FlattenedCourseSchedule {
|
||||
const [activeSchedule] = useSchedules();
|
||||
|
||||
if (!activeSchedule) {
|
||||
return {
|
||||
courseCells: [] as CalendarGridCourse[],
|
||||
activeSchedule: new UserSchedule({
|
||||
courses: [],
|
||||
id: 'error',
|
||||
name: 'Something may have went wrong',
|
||||
hours: 0,
|
||||
updatedAt: Date.now(),
|
||||
}),
|
||||
} satisfies FlattenedCourseSchedule;
|
||||
}
|
||||
|
||||
if (activeSchedule.courses.length === 0) {
|
||||
return {
|
||||
courseCells: [] as CalendarGridCourse[],
|
||||
activeSchedule,
|
||||
} satisfies FlattenedCourseSchedule;
|
||||
}
|
||||
|
||||
const { courses, name, hours } = activeSchedule;
|
||||
|
||||
const processedCourses = courses
|
||||
.flatMap((course: Course) => {
|
||||
const processedCourses = activeSchedule.courses
|
||||
.flatMap(course => {
|
||||
const { status, courseDeptAndInstr, meetings } = extractCourseInfo(course);
|
||||
|
||||
if (meetings.length === 0) {
|
||||
return processAsyncCourses({ courseDeptAndInstr, status, course });
|
||||
}
|
||||
|
||||
return meetings.flatMap((meeting: CourseMeeting) =>
|
||||
processInPersonMeetings(meeting, { courseDeptAndInstr, status, course })
|
||||
);
|
||||
return meetings.flatMap(meeting => processInPersonMeetings(meeting, courseDeptAndInstr, status, course));
|
||||
})
|
||||
.sort(sortCourses);
|
||||
|
||||
return {
|
||||
courseCells: processedCourses as CalendarGridCourse[],
|
||||
activeSchedule: { name, courses, hours } as UserSchedule,
|
||||
} satisfies FlattenedCourseSchedule;
|
||||
courseCells: processedCourses,
|
||||
activeSchedule,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,7 +87,7 @@ function extractCourseInfo(course: Course) {
|
||||
status,
|
||||
schedule: { meetings },
|
||||
} = course;
|
||||
const courseDeptAndInstr = `${course.department} ${course.number} – ${course.instructors[0].lastName}`;
|
||||
const courseDeptAndInstr = `${course.department} ${course.number} – ${course.instructors[0]?.lastName}`;
|
||||
|
||||
return { status, courseDeptAndInstr, meetings, course };
|
||||
}
|
||||
@@ -149,12 +127,17 @@ function processAsyncCourses({
|
||||
/**
|
||||
* Function to process each in-person class into its distinct meeting objects for calendar grid
|
||||
*/
|
||||
function processInPersonMeetings(meeting: CourseMeeting, { courseDeptAndInstr, status, course }) {
|
||||
function processInPersonMeetings(
|
||||
meeting: CourseMeeting,
|
||||
courseDeptAndInstr: string,
|
||||
status: StatusType,
|
||||
course: Course
|
||||
) {
|
||||
const { days, startTime, endTime, location } = meeting;
|
||||
const midnightIndex = 1440;
|
||||
const normalizingTimeFactor = 720;
|
||||
const time = meeting.getTimeString({ separator: '-', capitalize: true });
|
||||
const timeAndLocation = `${time} - ${location ? location.building : 'WB'}`;
|
||||
const timeAndLocation = `${time}${location ? ` - ${location.building}` : ''}`;
|
||||
const normalizedStartTime = startTime >= midnightIndex ? startTime - normalizingTimeFactor : startTime;
|
||||
const normalizedEndTime = endTime >= midnightIndex ? endTime - normalizingTimeFactor : endTime;
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@ import { useEffect } from 'react';
|
||||
* @param key the key to listen for
|
||||
* @param callback the callback to call when the key is pressed
|
||||
*/
|
||||
export function useKeyPress(key: string, callback: (...args: any[]) => void): void {
|
||||
export function useKeyPress(key: string, callback: (event: KeyboardEvent) => void): void {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === key) {
|
||||
callback();
|
||||
callback(event);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
||||
import { UserSchedule } from '@shared/types/UserSchedule';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
let schedulesCache = [];
|
||||
let schedulesCache: UserSchedule[] = [];
|
||||
let activeIndexCache = -1;
|
||||
let initialLoad = true;
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ export class CourseCatalogScraper {
|
||||
.filter(Boolean);
|
||||
|
||||
return names.map(fullName => {
|
||||
const [lastName, rest] = fullName.split(',').map(s => s.trim());
|
||||
const [lastName, rest = ''] = fullName.split(',').map(s => s.trim());
|
||||
const [firstName, middleInitial] = rest.split(' ');
|
||||
|
||||
return new Instructor({
|
||||
@@ -334,13 +334,11 @@ export class CourseCatalogScraper {
|
||||
const schedule = new CourseSchedule();
|
||||
|
||||
for (let i = 0; i < dayLines.length; i += 1) {
|
||||
schedule.meetings.push(
|
||||
CourseSchedule.parse(
|
||||
dayLines[i].textContent || '',
|
||||
hourLines[i].textContent || '',
|
||||
locLines[i].textContent || ''
|
||||
)
|
||||
);
|
||||
const dayText = dayLines[i]?.textContent || '';
|
||||
const hourText = hourLines[i]?.textContent || '';
|
||||
const locationText = locLines[i]?.textContent || '';
|
||||
|
||||
schedule.meetings.push(CourseSchedule.parse(dayText, hourText, locationText));
|
||||
}
|
||||
|
||||
return schedule;
|
||||
|
||||
@@ -20,7 +20,7 @@ export async function queryAggregateDistribution(course: Course): Promise<[Distr
|
||||
|
||||
let row: Required<CourseSQLRow> = {} as Required<CourseSQLRow>;
|
||||
res.columns.forEach((col, i) => {
|
||||
row[res.columns[i]] = res.values[0][i];
|
||||
row[col as keyof CourseSQLRow] = res.values[0]![i]! as never;
|
||||
});
|
||||
|
||||
const distribution: Distribution = {
|
||||
@@ -48,6 +48,9 @@ export async function queryAggregateDistribution(course: Course): Promise<[Distr
|
||||
|
||||
rawSemesters.forEach((sem: string) => {
|
||||
const [season, year] = sem.split(' ');
|
||||
if (!season || !year) {
|
||||
throw new Error('Season is undefined');
|
||||
}
|
||||
semesters.push({ year: parseInt(year, 10), season: season as Semester['season'] });
|
||||
});
|
||||
|
||||
@@ -92,7 +95,7 @@ export async function querySemesterDistribution(course: Course, semester: Semest
|
||||
|
||||
let row: Required<CourseSQLRow> = {} as Required<CourseSQLRow>;
|
||||
res.columns.forEach((col, i) => {
|
||||
row[res.columns[i]] = res.values[0][i];
|
||||
row[col as keyof CourseSQLRow] = res.values[0]![i]! as never;
|
||||
});
|
||||
|
||||
const distribution: Distribution = {
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
"types": ["vite/client", "unplugin-icons/types/react", "node"],
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"moduleDetection": "force",
|
||||
"isolatedModules": true,
|
||||
"paths": {
|
||||
"src/*": ["src/*"],
|
||||
"@assets/*": ["src/assets/*"],
|
||||
|
||||
@@ -4,7 +4,7 @@ import react from '@vitejs/plugin-react-swc';
|
||||
import { resolve } from 'path';
|
||||
import UnoCSS from 'unocss/vite';
|
||||
import Icons from 'unplugin-icons/vite';
|
||||
import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite';
|
||||
import type { Plugin, ResolvedConfig, Rollup, ViteDevServer } from 'vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import inspect from 'vite-plugin-inspect';
|
||||
|
||||
@@ -26,9 +26,11 @@ window.$RefreshSig$ = () => (type) => type
|
||||
window.__vite_plugin_react_preamble_installed__ = true
|
||||
`;
|
||||
|
||||
const isOutputChunk = (input: Rollup.OutputAsset | Rollup.OutputChunk): input is Rollup.OutputChunk => 'code' in input;
|
||||
|
||||
const renameFile = (source: string, destination: string): Plugin => {
|
||||
if (typeof source !== 'string' || typeof destination !== 'string') {
|
||||
return;
|
||||
throw new Error('Invalid arguments for renameFile');
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -36,25 +38,30 @@ const renameFile = (source: string, destination: string): Plugin => {
|
||||
apply: 'build',
|
||||
enforce: 'post',
|
||||
generateBundle(options, bundle) {
|
||||
if (!bundle[source]) return;
|
||||
bundle[source].fileName = destination;
|
||||
const file = bundle[source];
|
||||
if (!file) return;
|
||||
file.fileName = destination;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const fixManifestOptionsPage = () => ({
|
||||
const fixManifestOptionsPage = (): Plugin => ({
|
||||
name: 'fix-manifest-options-page',
|
||||
apply: 'build' as const,
|
||||
enforce: 'post' as const,
|
||||
apply: 'build',
|
||||
enforce: 'post',
|
||||
generateBundle(_, bundle) {
|
||||
for (const fileName of Object.keys(bundle)) {
|
||||
if (fileName.startsWith('assets/crx-manifest')) {
|
||||
const chunk = bundle[fileName];
|
||||
chunk.code = chunk.code.replace(
|
||||
/"options_page":"src\/pages\/options\/index.html"/,
|
||||
`"options_page":"options.html"`
|
||||
);
|
||||
break;
|
||||
if (!chunk) continue;
|
||||
|
||||
if (isOutputChunk(chunk)) {
|
||||
chunk.code = chunk.code.replace(
|
||||
/"options_page":"src\/pages\/options\/index.html"/,
|
||||
`"options_page":"options.html"`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -126,6 +133,7 @@ export default defineConfig({
|
||||
return code;
|
||||
},
|
||||
},
|
||||
renameFile('src/pages/debug/index.html', 'debug.html'),
|
||||
renameFile('src/pages/options/index.html', 'options.html'),
|
||||
renameFile('src/pages/calendar/index.html', 'calendar.html'),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user