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:
doprz
2024-03-21 13:19:40 -05:00
committed by GitHub
parent 0c76052478
commit efed1c0edb
61 changed files with 562 additions and 1309 deletions

View File

@@ -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',

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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 />);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);
}
},

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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({

View File

@@ -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;
}

View File

@@ -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 />);

View File

@@ -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 />);

View File

@@ -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 />);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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}`);
}

View File

@@ -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);
}

View File

@@ -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.

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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>>;

View File

@@ -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();

View File

@@ -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 => (

View File

@@ -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>);
}

View File

@@ -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,
},
],

View File

@@ -16,7 +16,6 @@ const meta = {
argTypes: {
course: { control: 'object' },
meetingIdx: { control: 'number' },
rightIcon: { control: 'object' },
},
} satisfies Meta<typeof CalendarCourse>;
export default meta;

View File

@@ -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),
},

View File

@@ -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)}

View File

@@ -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)}

View File

@@ -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 })}${

View File

@@ -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'>

View File

@@ -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',
}}
>

View File

@@ -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`;
});
});

View File

@@ -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}

View File

@@ -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;
};

View File

@@ -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>

View File

@@ -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}>

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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
)}

View File

@@ -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>

View File

@@ -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;
}
/**

View File

@@ -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 (

View File

@@ -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(

View File

@@ -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;

View File

@@ -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);

View File

@@ -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} />
</>

View File

@@ -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 && (
<>

View File

@@ -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(() => {

View File

@@ -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]);

View File

@@ -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]);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 = {

View File

@@ -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/*"],

View File

@@ -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'),
],