feat: settings page (#260)

* feat: setup settings page boilerplate

* feat: split view into halves

* feat: add preview for Customization Options section

* feat: add OptionStore logic and LD icon

* feat: add courseStatusChips functionality

* feat: migrate experimental settings to proper settings

* feat: center Preview children and add className override

* feat: add GitHub stats

* feat: open GitHub user profile onclick

* feat: get user GitHub stats

* feat: refactor into useGitHubStats hook

* feat: toggle GitHub stats when the user presses the 'S' key

* chore: update title

* fix: remove extra file

* feat: refactor and add DialogProvider

* fix: import

* test: this commit has issues

* fix: no schedule bug

* fix: longhorn developers icon not rendering in prod builds

* feat(pr-review): fix UI and comment out experimental code

* chore: run lint and prettier

* feat: add responsive design

* feat: use @octokit/rest and fix GitHub stats
This commit is contained in:
doprz
2024-10-10 18:05:19 -05:00
committed by GitHub
parent d73615e281
commit 7a5c3a2e62
23 changed files with 1758 additions and 661 deletions

View File

@@ -27,6 +27,7 @@
"dependencies": {
"@headlessui/react": "^2.1.8",
"@hello-pangea/dnd": "^17.0.0",
"@octokit/rest": "^21.0.2",
"@unocss/vite": "^0.63.2",
"@vitejs/plugin-react": "^4.3.2",
"chrome-extension-toolkit": "^0.0.54",
@@ -35,6 +36,7 @@
"highcharts-react-official": "^3.2.1",
"html-to-image": "^1.11.11",
"husky": "^9.1.6",
"kc-dabr-wasm": "^0.1.2",
"nanoid": "^5.0.7",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -49,6 +51,7 @@
"@commitlint/types": "^19.5.0",
"@crxjs/vite-plugin": "2.0.0-beta.21",
"@iconify-json/bi": "^1.2.0",
"@iconify-json/iconoir": "^1.2.1",
"@iconify-json/material-symbols": "^1.2.2",
"@iconify-json/ri": "^1.2.0",
"@storybook/addon-designs": "^8.0.3",

1386
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -1,4 +1,5 @@
import { DevStore } from '@shared/storage/DevStore';
import useKC_DABR_WASM from 'kc-dabr-wasm';
import React, { useEffect } from 'react';
import { createRoot } from 'react-dom/client';
@@ -77,6 +78,7 @@ function DevDashboard() {
const [localStorage, setLocalStorage] = React.useState<Record<string, unknown>>({});
const [syncStorage, setSyncStorage] = React.useState<Record<string, unknown>>({});
const [sessionStorage, setSessionStorage] = React.useState<Record<string, unknown>>({});
useKC_DABR_WASM();
useEffect(() => {
const onVisibilityChange = () => {

View File

@@ -1,5 +1,7 @@
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
import createSchedule from './createSchedule';
/**
* Deletes a schedule with the specified name.
*
@@ -32,3 +34,14 @@ export default async function deleteSchedule(scheduleId: string): Promise<string
}
return undefined;
}
/**
* Deletes all schedules.
*
* @returns A promise that resolves when all schedules are deleted
*/
export async function deleteAllSchedules(): Promise<void> {
await UserScheduleStore.set('schedules', []);
await UserScheduleStore.set('activeIndex', 0);
await createSchedule('Schedule 1');
}

View File

@@ -3,6 +3,7 @@ import Calendar from '@views/components/calendar/Calendar';
import DialogProvider from '@views/components/common/DialogProvider/DialogProvider';
import ExtensionRoot from '@views/components/common/ExtensionRoot/ExtensionRoot';
import { MessageListener } from 'chrome-extension-toolkit';
import useKC_DABR_WASM from 'kc-dabr-wasm';
import React, { useEffect } from 'react';
/**
@@ -10,6 +11,7 @@ import React, { useEffect } from 'react';
* @returns entire page
*/
export default function CalendarMain() {
useKC_DABR_WASM();
useEffect(() => {
const tabInfoListener = new MessageListener<TabInfoMessages>({
getTabInfo: ({ sendResponse }) => {

View File

@@ -0,0 +1,22 @@
import DialogProvider from '@views/components/common/DialogProvider/DialogProvider';
import ExtensionRoot from '@views/components/common/ExtensionRoot/ExtensionRoot';
import Settings from '@views/components/settings/Settings';
import useKC_DABR_WASM from 'kc-dabr-wasm';
import React from 'react';
/**
* Renders the settings page for the UTRP (UT Registration Plus) extension.
* Allows customization options and displays credits for the development team.
*
* @returns The JSX element representing the settings page.
*/
export default function SettingsPage() {
useKC_DABR_WASM();
return (
<ExtensionRoot>
<DialogProvider>
<Settings />
</DialogProvider>
</ExtensionRoot>
);
}

View File

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

View File

@@ -3,20 +3,44 @@ import { createSyncStore, debugStore } from 'chrome-extension-toolkit';
/**
* A store that is used for storing user options
*/
interface IOptionsStore {
/** whether we should automatically highlight conflicts on the course schedule page */
shouldHighlightConflicts: boolean;
/** whether we should automatically scroll to load more courses on the course schedule page (without having to click next) */
shouldScrollToLoad: boolean;
export interface IOptionsStore {
/** whether we should enable course status chips (indicator for waitlisted, cancelled, and closed courses) */
enableCourseStatusChips: boolean;
// url: URL;
/** whether we should enable course's time and location in the extension's popup */
enableTimeAndLocationInPopup: boolean;
/** whether we should automatically highlight conflicts on the course schedule page (adds a red strikethrough to courses that have conflicting times) */
enableHighlightConflicts: boolean;
/** whether we should automatically scroll to load more courses on the course schedule page (without having to click next) */
enableScrollToLoad: boolean;
/** whether we should automatically refresh the data for the waitlist, course status, and other info with the latest data from UT's site */
enableDataRefreshing: boolean;
}
export const OptionsStore = createSyncStore<IOptionsStore>({
shouldHighlightConflicts: true,
shouldScrollToLoad: true,
enableCourseStatusChips: false,
enableTimeAndLocationInPopup: false,
enableHighlightConflicts: true,
enableScrollToLoad: true,
enableDataRefreshing: true,
});
/**
* Initializes the settings by retrieving the values from the OptionsStore.
* @returns {Promise<IOptionsStore>} A promise that resolves to an object satisfying the IOptionsStore interface.
*/
export const initSettings = async () =>
({
enableCourseStatusChips: await OptionsStore.get('enableCourseStatusChips'),
enableTimeAndLocationInPopup: await OptionsStore.get('enableTimeAndLocationInPopup'),
enableHighlightConflicts: await OptionsStore.get('enableHighlightConflicts'),
enableScrollToLoad: await OptionsStore.get('enableScrollToLoad'),
enableDataRefreshing: await OptionsStore.get('enableDataRefreshing'),
}) satisfies IOptionsStore;
// Clothing retailer right
debugStore({ OptionsStore });

View File

@@ -1,3 +0,0 @@
export const enableCourseStatusChips: boolean = false;
export const constenableSettingsPage: boolean = false;
export const enableCourseRefreshing: boolean = false;

View File

@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react';
import Settings from '@views/components/Settings';
import Settings from '@views/components/settings/Settings';
const meta = {
title: 'Components/Common/Settings',

View File

@@ -1,7 +1,7 @@
import splashText from '@assets/insideJokes';
import { background } from '@shared/messages';
import { initSettings, OptionsStore } from '@shared/storage/OptionsStore';
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
import { enableCourseRefreshing, enableCourseStatusChips } from '@shared/util/experimental';
import Divider from '@views/components/common/Divider';
import ExtensionRoot from '@views/components/common/ExtensionRoot/ExtensionRoot';
import List from '@views/components/common/List';
@@ -10,6 +10,7 @@ import useSchedules, { getActiveSchedule, replaceSchedule, switchSchedule } from
import { getUpdatedAtDateTimeString } from '@views/lib/getUpdatedAtDateTimeString';
import { openTabFromContentScript } from '@views/lib/openNewTabFromContentScript';
import clsx from 'clsx';
import useKC_DABR_WASM from 'kc-dabr-wasm';
import React, { useEffect, useState } from 'react';
import CalendarIcon from '~icons/material-symbols/calendar-month';
@@ -28,6 +29,32 @@ import ScheduleListItem from './common/ScheduleListItem';
* This component displays the main schedule, courses, and options buttons.
*/
export default function PopupMain(): JSX.Element {
const [enableCourseStatusChips, setEnableCourseStatusChips] = useState<boolean>(false);
const [enableDataRefreshing, setEnableDataRefreshing] = useState<boolean>(false);
useKC_DABR_WASM();
useEffect(() => {
initSettings().then(({ enableCourseStatusChips, enableDataRefreshing }) => {
setEnableCourseStatusChips(enableCourseStatusChips);
setEnableDataRefreshing(enableDataRefreshing);
});
const l1 = OptionsStore.listen('enableCourseStatusChips', async ({ newValue }) => {
setEnableCourseStatusChips(newValue);
// console.log('enableCourseStatusChips', newValue);
});
const l2 = OptionsStore.listen('enableDataRefreshing', async ({ newValue }) => {
setEnableDataRefreshing(newValue);
// console.log('enableDataRefreshing', newValue);
});
return () => {
OptionsStore.removeListener(l1);
OptionsStore.removeListener(l2);
};
}, []);
const [activeSchedule, schedules] = useSchedules();
const [isRefreshing, setIsRefreshing] = useState(false);
const [funny, setFunny] = useState<string>('');
@@ -139,7 +166,7 @@ export default function PopupMain(): JSX.Element {
</>
)}
</div>
{enableCourseRefreshing && (
{enableDataRefreshing && (
<div className='inline-flex items-center self-center gap-1'>
<Text variant='mini' className='text-ut-gray !font-normal'>
DATA LAST UPDATED: {getUpdatedAtDateTimeString(activeSchedule.updatedAt)}

View File

@@ -1,15 +0,0 @@
import React from 'react';
type Props = {
className?: string;
};
/**
* Component to hold everything for the settings page
* @param props className
* @returns The content for the settings page
*/
export default function Settings({ className }: Props): JSX.Element {
// TODO: Implement the settings page
return <div className={className}>this will be finished laterrrrrrr</div>;
}

View File

@@ -1,11 +1,11 @@
import { initSettings, OptionsStore } from '@shared/storage/OptionsStore';
import type { StatusType } from '@shared/types/Course';
import { Status } from '@shared/types/Course';
import type { CourseColors } from '@shared/types/ThemeColors';
import { pickFontColor } from '@shared/util/colors';
import { enableCourseStatusChips } from '@shared/util/experimental';
import Text from '@views/components/common/Text/Text';
import clsx from 'clsx';
import React from 'react';
import React, { useEffect, useState } from 'react';
import ClosedIcon from '~icons/material-symbols/lock';
import WaitlistIcon from '~icons/material-symbols/timelapse';
@@ -43,6 +43,21 @@ export default function CalendarCourseCell({
className,
onClick,
}: CalendarCourseCellProps): JSX.Element {
const [enableCourseStatusChips, setEnableCourseStatusChips] = useState<boolean>(false);
useEffect(() => {
initSettings().then(({ enableCourseStatusChips }) => setEnableCourseStatusChips(enableCourseStatusChips));
const l1 = OptionsStore.listen('enableCourseStatusChips', async ({ newValue }) => {
setEnableCourseStatusChips(newValue);
// console.log('enableCourseStatusChips', newValue);
});
return () => {
OptionsStore.removeListener(l1);
};
}, []);
let rightIcon: React.ReactNode | null = null;
if (enableCourseStatusChips) {
if (status === Status.WAITLISTED) {

View File

@@ -1,5 +1,5 @@
import { initSettings, OptionsStore } from '@shared/storage/OptionsStore';
import { Status } from '@shared/types/Course';
import { enableCourseRefreshing, enableCourseStatusChips } from '@shared/util/experimental';
import { Button } from '@views/components/common/Button';
import CourseStatus from '@views/components/common/CourseStatus';
import Divider from '@views/components/common/Divider';
@@ -9,7 +9,7 @@ import Text from '@views/components/common/Text/Text';
import useSchedules from '@views/hooks/useSchedules';
import { getUpdatedAtDateTimeString } from '@views/lib/getUpdatedAtDateTimeString';
import { openTabFromContentScript } from '@views/lib/openNewTabFromContentScript';
import React from 'react';
import React, { useEffect, useState } from 'react';
import MenuIcon from '~icons/material-symbols/menu';
import RefreshIcon from '~icons/material-symbols/refresh';
@@ -32,8 +32,33 @@ interface CalendarHeaderProps {
* @returns The JSX element representing the calendar header.
*/
export default function CalendarHeader({ onSidebarToggle }: CalendarHeaderProps): JSX.Element {
const [enableCourseStatusChips, setEnableCourseStatusChips] = useState<boolean>(false);
const [enableDataRefreshing, setEnableDataRefreshing] = useState<boolean>(false);
const [activeSchedule] = useSchedules();
useEffect(() => {
initSettings().then(({ enableCourseStatusChips, enableDataRefreshing }) => {
setEnableCourseStatusChips(enableCourseStatusChips);
setEnableDataRefreshing(enableDataRefreshing);
});
const l1 = OptionsStore.listen('enableCourseStatusChips', async ({ newValue }) => {
setEnableCourseStatusChips(newValue);
// console.log('enableCourseStatusChips', newValue);
});
const l2 = OptionsStore.listen('enableDataRefreshing', async ({ newValue }) => {
setEnableDataRefreshing(newValue);
// console.log('enableDataRefreshing', newValue);
});
return () => {
OptionsStore.removeListener(l1);
OptionsStore.removeListener(l2);
};
}, []);
return (
<div className='flex items-center gap-5 overflow-x-auto overflow-y-hidden border-b border-ut-offwhite px-7 py-4 md:overflow-x-hidden'>
<Button
@@ -51,7 +76,7 @@ export default function CalendarHeader({ onSidebarToggle }: CalendarHeaderProps)
totalHours={activeSchedule.hours}
totalCourses={activeSchedule.courses.length}
/>
{enableCourseRefreshing && (
{enableDataRefreshing && (
<div className='flex items-center gap-1 screenshot:hidden'>
<Text variant='mini' className='text-nowrap text-ut-gray font-normal!'>
DATA LAST UPDATED: {getUpdatedAtDateTimeString(activeSchedule.updatedAt)}

View File

@@ -1,14 +1,14 @@
import type { DraggableProvidedDragHandleProps } from '@hello-pangea/dnd';
import { background } from '@shared/messages';
import { initSettings, OptionsStore } from '@shared/storage/OptionsStore';
import type { Course } from '@shared/types/Course';
import { Status } from '@shared/types/Course';
import type { CourseColors } from '@shared/types/ThemeColors';
import { pickFontColor } from '@shared/util/colors';
import { enableCourseStatusChips } from '@shared/util/experimental';
import { StatusIcon } from '@shared/util/icons';
import Text from '@views/components/common/Text/Text';
import clsx from 'clsx';
import React from 'react';
import React, { useEffect, useState } from 'react';
import DragIndicatorIcon from '~icons/material-symbols/drag-indicator';
@@ -33,6 +33,21 @@ export default function PopupCourseBlock({
colors,
dragHandleProps,
}: PopupCourseBlockProps): JSX.Element {
const [enableCourseStatusChips, setEnableCourseStatusChips] = useState<boolean>(false);
useEffect(() => {
initSettings().then(({ enableCourseStatusChips }) => setEnableCourseStatusChips(enableCourseStatusChips));
const l1 = OptionsStore.listen('enableCourseStatusChips', async ({ newValue }) => {
setEnableCourseStatusChips(newValue);
// console.log('enableCourseStatusChips', newValue);
});
return () => {
OptionsStore.removeListener(l1);
};
}, []);
// text-white or text-black based on secondaryColor
const fontColor = pickFontColor(colors.primaryColor);
const formattedUniqueId = course.uniqueId.toString().padStart(5, '0');

View File

@@ -34,7 +34,7 @@ const SwitchButton = ({ isChecked = true, onChange }: ToggleSwitchProps): JSX.El
checked={enabled}
onChange={handleChange}
className={`${enabled ? 'bg-[#579D42]' : 'bg-gray-400'}
relative inline-flex items-center h-8 w-13 rounded-full transition-colors ease-in-out duration-200`}
relative inline-flex items-center h-8 w-13 rounded-full transition-colors ease-in-out duration-200 min-w-[52px]`}
>
<span
className={`${enabled ? 'translate-x-6' : 'translate-x-1'}

View File

@@ -86,3 +86,10 @@
text-decoration: line-through;
}
}
.isConflictNoLineThrough:not(.inActiveSchedule) {
> *:not(td:last-child) {
color: colors.$speedway_brick;
font-weight: normal;
}
}

View File

@@ -1,3 +1,4 @@
import { initSettings, OptionsStore } from '@shared/storage/OptionsStore';
import type { Course, ScrapedRow } from '@shared/types/Course';
import type { UserSchedule } from '@shared/types/UserSchedule';
import ConflictsWithWarning from '@views/components/common/ConflictsWithWarning';
@@ -25,9 +26,26 @@ export default function TableRow({ row, isSelected, activeSchedule, onClick }: P
// the courses in the active schedule that conflict with the course for this row
const [conflicts, setConflicts] = useState<Course[]>([]);
const [highlightConflicts, setHighlightConflicts] = useState<boolean>(false);
const { element, course } = row;
useEffect(() => {
initSettings().then(({ enableHighlightConflicts }) => {
setHighlightConflicts(enableHighlightConflicts);
});
const l1 = OptionsStore.listen('enableHighlightConflicts', async ({ newValue }) => {
setHighlightConflicts(newValue);
// console.log('enableHighlightConflicts', newValue);
});
// Remove listeners when the component is unmounted
return () => {
OptionsStore.removeListener(l1);
};
}, []);
useEffect(() => {
element.classList.add(styles.row!);
element.classList.add('group');
@@ -72,14 +90,23 @@ export default function TableRow({ row, isSelected, activeSchedule, onClick }: P
}
}
element.classList[conflicts.length ? 'add' : 'remove'](styles.isConflict!);
// Clear conflict styling
element.classList.remove(styles.isConflict!);
element.classList.remove(styles.isConflictNoLineThrough!);
if (highlightConflicts) {
element.classList[conflicts.length ? 'add' : 'remove'](styles.isConflict!);
} else {
element.classList[conflicts.length ? 'add' : 'remove'](styles.isConflictNoLineThrough!);
}
setConflicts(conflicts);
return () => {
element.classList.remove(styles.isConflict!);
setConflicts([]);
};
}, [activeSchedule, course, element.classList]);
}, [activeSchedule, course, element.classList, highlightConflicts]);
if (!container) {
return null;

View File

@@ -3,9 +3,11 @@ import Link from '@views/components/common/Link';
import React from 'react';
/**
* Renders the DevMode component.
*
* @returns The rendered DevMode component.
*/
export default function App() {
export default function DevMode() {
return (
<ExtensionRoot>
<div className='text-base'>

View File

@@ -0,0 +1,30 @@
import clsx from 'clsx';
import type { PropsWithChildren } from 'react';
import React from 'react';
/**
* Props for the Preview component.
*/
export interface PreviewProps {
className?: string;
}
/**
* Renders a preview component.
*
* @param props - The component props.
* @returns The rendered preview component.
*/
export default function Preview(props: PropsWithChildren<PreviewProps>): JSX.Element {
const { children } = props;
return (
<div className='w-1/2 inline-flex flex-col items-start justify-start rounded-xl bg-ut-gray/10'>
<div className='m-4 inline-flex items-center self-stretch justify-end gap-2.5'>
<div className='text-center text-sm text-ut-gray font-medium'>Preview</div>
</div>
<div className={clsx('h-full flex flex-col self-stretch justify-center p-5 space-y-4', props.className)}>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,487 @@
import { deleteAllSchedules } from '@pages/background/lib/deleteSchedule';
import { initSettings, OptionsStore } from '@shared/storage/OptionsStore';
// import { getCourseColors } from '@shared/util/colors';
// import CalendarCourseCell from '@views/components/calendar/CalendarCourseCell';
import { Button } from '@views/components/common/Button';
import { usePrompt } from '@views/components/common/DialogProvider/DialogProvider';
import Divider from '@views/components/common/Divider';
import { SmallLogo } from '@views/components/common/LogoIcon';
// import PopupCourseBlock from '@views/components/common/PopupCourseBlock';
import SwitchButton from '@views/components/common/SwitchButton';
import Text from '@views/components/common/Text/Text';
import useSchedules from '@views/hooks/useSchedules';
import { GitHubStatsService, LONGHORN_DEVELOPERS_ADMINS } from '@views/lib/getGitHubStats';
import { getUpdatedAtDateTimeString } from '@views/lib/getUpdatedAtDateTimeString';
import clsx from 'clsx';
import React, { useCallback, useEffect, useState } from 'react';
import IconoirGitFork from '~icons/iconoir/git-fork';
// import { ExampleCourse } from 'src/stories/components/ConflictsWithWarning.stories';
import DeleteForeverIcon from '~icons/material-symbols/delete-forever';
// import RefreshIcon from '~icons/material-symbols/refresh';
import DevMode from './DevMode';
import Preview from './Preview';
const DISPLAY_PREVIEWS = false;
const PREVIEW_SECTION_DIV_CLASSNAME = DISPLAY_PREVIEWS ? 'w-1/2 space-y-4' : 'flex-grow space-y-4';
const manifest = chrome.runtime.getManifest();
const LDIconURL = new URL('/src/assets/LD-icon.png', import.meta.url).href;
const gitHubStatsService = new GitHubStatsService();
const includeMergedPRs = false;
/**
* Custom hook for enabling developer mode.
*
* @param targetCount - The target count to activate developer mode.
* @returns A tuple containing a boolean indicating if developer mode is active and a function to increment the count.
*/
const useDevMode = (targetCount: number): [boolean, () => void] => {
const [count, setCount] = useState(0);
const [active, setActive] = useState(false);
const [lastClick, setLastClick] = useState(0);
const incrementCount = useCallback(() => {
const now = Date.now();
if (now - lastClick < 500) {
setCount(prevCount => {
const newCount = prevCount + 1;
if (newCount === targetCount) {
setActive(true);
}
return newCount;
});
} else {
setCount(1);
}
setLastClick(now);
}, [lastClick, targetCount]);
useEffect(() => {
const timer = setTimeout(() => setCount(0), 3000);
return () => clearTimeout(timer);
}, [count]);
return [active, incrementCount];
};
/**
* Component for managing user settings and preferences.
*
* @returns The Settings component.
*/
export default function Settings(): JSX.Element {
const [enableCourseStatusChips, setEnableCourseStatusChips] = useState<boolean>(false);
const [showTimeLocation, setShowTimeLocation] = useState<boolean>(false);
const [highlightConflicts, setHighlightConflicts] = useState<boolean>(false);
const [loadAllCourses, setLoadAllCourses] = useState<boolean>(false);
const [enableDataRefreshing, setEnableDataRefreshing] = useState<boolean>(false);
// Toggle GitHub stats when the user presses the 'S' key
const [showGitHubStats, setShowGitHubStats] = useState<boolean>(false);
const [githubStats, setGitHubStats] = useState<Awaited<
ReturnType<typeof gitHubStatsService.fetchGitHubStats>
> | null>(null);
const [activeSchedule] = useSchedules();
// const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
const showDialog = usePrompt();
useEffect(() => {
const fetchGitHubStats = async () => {
const stats = await gitHubStatsService.fetchGitHubStats();
setGitHubStats(stats);
};
const initAndSetSettings = async () => {
const {
enableCourseStatusChips,
enableTimeAndLocationInPopup,
enableHighlightConflicts,
enableScrollToLoad,
enableDataRefreshing,
} = await initSettings();
setEnableCourseStatusChips(enableCourseStatusChips);
setShowTimeLocation(enableTimeAndLocationInPopup);
setHighlightConflicts(enableHighlightConflicts);
setLoadAllCourses(enableScrollToLoad);
setEnableDataRefreshing(enableDataRefreshing);
};
fetchGitHubStats();
initAndSetSettings();
const handleKeyPress = (event: KeyboardEvent) => {
if (event.key === 'S' || event.key === 's') {
setShowGitHubStats(prev => !prev);
}
};
window.addEventListener('keydown', handleKeyPress);
// Listen for changes in the settings
const l1 = OptionsStore.listen('enableCourseStatusChips', async ({ newValue }) => {
setEnableCourseStatusChips(newValue);
// console.log('enableCourseStatusChips', newValue);
});
const l2 = OptionsStore.listen('enableTimeAndLocationInPopup', async ({ newValue }) => {
setShowTimeLocation(newValue);
// console.log('enableTimeAndLocationInPopup', newValue);
});
const l3 = OptionsStore.listen('enableHighlightConflicts', async ({ newValue }) => {
setHighlightConflicts(newValue);
// console.log('enableHighlightConflicts', newValue);
});
const l4 = OptionsStore.listen('enableScrollToLoad', async ({ newValue }) => {
setLoadAllCourses(newValue);
// console.log('enableScrollToLoad', newValue);
});
const l5 = OptionsStore.listen('enableDataRefreshing', async ({ newValue }) => {
setEnableDataRefreshing(newValue);
// console.log('enableDataRefreshing', newValue);
});
// Remove listeners when the component is unmounted
return () => {
OptionsStore.removeListener(l1);
OptionsStore.removeListener(l2);
OptionsStore.removeListener(l3);
OptionsStore.removeListener(l4);
OptionsStore.removeListener(l5);
window.removeEventListener('keydown', handleKeyPress);
};
}, []);
const handleEraseAll = () => {
showDialog({
title: 'Erase All Course/Schedule Data',
description: (
<>
<p>
Are you sure you want to erase all schedules and courses you have? This action is permanent and
cannot be undone.
</p>
<br />
<p className='text-sm text-gray-600'>Note: This will not erase your settings and preferences.</p>
</>
),
// eslint-disable-next-line react/no-unstable-nested-components
buttons: accept => (
<Button
variant='filled'
color='ut-burntorange'
onClick={() => {
deleteAllSchedules();
accept();
}}
>
I Understand
</Button>
),
});
};
const [devMode, toggleDevMode] = useDevMode(10);
if (devMode) {
return <DevMode />;
}
return (
<div className='min-w-xl bg-white'>
<header className='flex items-center justify-between border-b p-6'>
<div className='flex items-center'>
<SmallLogo className='pr-4' />
<Divider size='2rem' orientation='vertical' />
<h1 className='pl-4 text-xl text-ut-burntorange font-bold'>UTRP SETTINGS & CREDITS PAGE</h1>
</div>
<div className='flex space-x-4'>
<div className='flex items-center'>
<IconoirGitFork className='h-6 w-6 text-ut-gray' />
<Text variant='small' className='text-ut-gray font-normal'>
v{manifest.version} - {process.env.NODE_ENV}
</Text>
</div>
<img src={LDIconURL} alt='LD Icon' className='h-10 w-10 rounded-lg' />
</div>
</header>
<div className='p-6 lg:flex'>
<div className='mr-4 lg:w-1/2 xl:w-xl'>
{/* <section className='mb-8'>
<h2 className='mb-4 text-xl text-ut-black font-semibold'>CUSTOMIZATION OPTIONS</h2>
<div className='flex space-x-4'>
<div className='w-1/2 space-y-4'>
<div className='flex items-center justify-between'>
<div className='max-w-xs'>
<h3 className='text-ut-burntorange font-semibold'>Show Course Status</h3>
<p className='text-sm text-gray-600'>
Shows an indicator for waitlisted, cancelled, and closed courses.
</p>
</div>
<SwitchButton
isChecked={enableCourseStatusChips}
onChange={() => {
setEnableCourseStatusChips(!enableCourseStatusChips);
OptionsStore.set('enableCourseStatusChips', !enableCourseStatusChips);
}}
/>
</div>
<Divider size='auto' orientation='horizontal' />
<div className='flex items-center justify-between'>
<div className='max-w-xs'>
<h3 className='text-ut-burntorange font-semibold'>
Show Time & Location in Popup
</h3>
<p className='text-sm text-gray-600'>
Shows the course&apos;s time and location in the extension&apos;s popup.
</p>
</div>
<SwitchButton
isChecked={showTimeLocation}
onChange={() => {
setShowTimeLocation(!showTimeLocation);
OptionsStore.set('enableTimeAndLocationInPopup', !showTimeLocation);
}}
/>
</div>
</div>
{DISPLAY_PREVIEWS && (
<Preview>
<CalendarCourseCell
colors={getCourseColors('orange')}
courseDeptAndInstr={ExampleCourse.department}
className={ExampleCourse.number}
status={ExampleCourse.status}
timeAndLocation={ExampleCourse.schedule.meetings[0]!.getTimeString({
separator: '-',
})}
/>
<PopupCourseBlock colors={getCourseColors('orange')} course={ExampleCourse} />
</Preview>
)}
</div>
</section>
<Divider size='auto' orientation='horizontal' /> */}
<section className='mb-8'>
<h2 className='mb-4 text-xl text-ut-black font-semibold'>ADVANCED SETTINGS</h2>
<div className='flex space-x-4'>
<div className={PREVIEW_SECTION_DIV_CLASSNAME}>
{/* <div className='flex items-center justify-between'>
<div className='max-w-xs'>
<h3 className='text-ut-burntorange font-semibold'>Refresh Data</h3>
<p className='text-sm text-gray-600'>
Refreshes waitlist, course status, and other info with the latest data from
UT&apos;s site.
</p>
</div>
<Button
variant='outline'
color='ut-black'
icon={RefreshIcon}
onClick={() => console.log('Refresh clicked')}
disabled={!enableDataRefreshing}
>
Refresh
</Button>
</div>
<Divider size='auto' orientation='horizontal' /> */}
<div className='flex items-center justify-between'>
<div className='max-w-xs'>
<Text variant='h4' className='text-ut-burntorange font-semibold'>
Course Conflict Highlight
</Text>
<p className='text-sm text-gray-600'>
Adds a red strikethrough to courses that have conflicting times.
</p>
</div>
<SwitchButton
isChecked={highlightConflicts}
onChange={() => {
setHighlightConflicts(!highlightConflicts);
OptionsStore.set('enableHighlightConflicts', !highlightConflicts);
}}
/>
</div>
<Divider size='auto' orientation='horizontal' />
<div className='flex items-center justify-between'>
<div className='max-w-xs'>
<Text variant='h4' className='text-ut-burntorange font-semibold'>
Load All Courses in Course Schedule
</Text>
<p className='text-sm text-gray-600'>
Loads all courses in the Course Schedule site by scrolling, instead of using
next/prev page buttons.
</p>
</div>
<SwitchButton
isChecked={loadAllCourses}
onChange={() => {
setLoadAllCourses(!loadAllCourses);
OptionsStore.set('enableScrollToLoad', !loadAllCourses);
}}
/>
</div>
<Divider size='auto' orientation='horizontal' />
<div className='flex items-center justify-between'>
<div className='max-w-xs'>
<Text variant='h4' className='text-ut-burntorange font-semibold'>
Reset All Data
</Text>
<p className='text-sm text-gray-600'>
Erases all schedules and courses you have.
</p>
</div>
<Button
variant='outline'
color='ut-red'
icon={DeleteForeverIcon}
onClick={handleEraseAll}
>
Erase All
</Button>
</div>
</div>
{DISPLAY_PREVIEWS && (
<Preview>
<div className='inline-flex items-center self-center gap-1'>
<Text variant='small' className='text-ut-gray !font-normal'>
DATA LAST UPDATED: {getUpdatedAtDateTimeString(activeSchedule.updatedAt)}
</Text>
</div>
<Text
variant='h2-course'
className={clsx('text-center text-ut-red !font-normal', {
'line-through': highlightConflicts,
})}
>
01234 MWF 10:00 AM - 11:00 AM UTC 1.234
</Text>
</Preview>
)}
</div>
</section>
<Divider size='auto' orientation='horizontal' />
<section className='my-8'>
<h2 className='mb-4 text-xl text-ut-black font-semibold' onClick={toggleDevMode}>
Developer Mode
</h2>
</section>
</div>
<Divider className='lg:hidden' size='auto' orientation='horizontal' />
<section className='my-8 lg:my-0 lg:ml-4 lg:w-1/2'>
<section>
<h2 className='mb-4 text-xl text-ut-black font-semibold'>LONGHORN DEVELOPERS ADMINS</h2>
<div className='grid grid-cols-2 gap-4 2xl:grid-cols-4 md:grid-cols-3'>
{LONGHORN_DEVELOPERS_ADMINS.map(admin => (
<div
key={admin.githubUsername}
className='border border-gray-300 rounded bg-ut-gray/10 p-4'
>
<Text
variant='p'
className='text-ut-burntorange font-semibold hover:cursor-pointer'
onClick={() =>
window.open(`https://github.com/${admin.githubUsername}`, '_blank')
}
>
{admin.name}
</Text>
<p className='text-sm text-gray-600'>{admin.role}</p>
{showGitHubStats && githubStats && (
<div className='mt-2'>
<p className='text-xs text-gray-500'>GitHub Stats (UTRP repo):</p>
{includeMergedPRs && (
<p className='text-xs'>
Merged PRS:{' '}
{githubStats.adminGitHubStats[admin.githubUsername]?.mergedPRs}
</p>
)}
<p className='text-xs'>
Commits: {githubStats.adminGitHubStats[admin.githubUsername]?.commits}
</p>
<p className='text-xs text-ut-green'>
{githubStats.adminGitHubStats[admin.githubUsername]?.linesAdded} ++
</p>
<p className='text-xs text-ut-red'>
{githubStats.adminGitHubStats[admin.githubUsername]?.linesDeleted} --
</p>
</div>
)}
</div>
))}
</div>
</section>
<section className='my-8'>
<h2 className='mb-4 text-xl text-ut-black font-semibold'>UTRP CONTRIBUTERS</h2>
<div className='grid grid-cols-2 gap-4 2xl:grid-cols-4 md:grid-cols-3 xl:grid-cols-3'>
{githubStats &&
Object.keys(githubStats.userGitHubStats)
.filter(
username =>
!LONGHORN_DEVELOPERS_ADMINS.some(admin => admin.githubUsername === username)
)
.map(username => (
<div
key={username}
className='overflow-clip border border-gray-300 rounded bg-ut-gray/10 p-4'
>
<Text
variant='p'
className='text-ut-burntorange font-semibold hover:cursor-pointer'
onClick={() => window.open(`https://github.com/${username}`, '_blank')}
>
@{username}
</Text>
<p className='text-sm text-gray-600'>Contributor</p>
{showGitHubStats && (
<div className='mt-2'>
<p className='text-xs text-gray-500'>GitHub Stats (UTRP repo):</p>
{includeMergedPRs && (
<p className='text-xs'>
Merged PRs:{' '}
{githubStats.userGitHubStats[username]?.mergedPRs}
</p>
)}
<p className='text-xs'>
Commits: {githubStats.userGitHubStats[username]?.commits}
</p>
<p className='text-xs text-ut-green'>
{githubStats.userGitHubStats[username]?.linesAdded} ++
</p>
<p className='text-xs text-ut-red'>
{githubStats.userGitHubStats[username]?.linesDeleted} --
</p>
</div>
)}
</div>
))}
</div>
</section>
</section>
</div>
</div>
);
}

View File

@@ -0,0 +1,266 @@
import { Octokit } from '@octokit/rest';
// Types
type TeamMember = {
name: string;
role: string;
githubUsername: string;
};
type GitHubStats = {
commits: number;
linesAdded: number;
linesDeleted: number;
mergedPRs?: number;
};
type ContributorStats = {
total: number;
weeks: { w: number; a: number; d: number; c: number }[];
author: { login: string };
};
type CachedData<T> = {
data: T;
dataFetched: Date;
};
type FetchResult<T> = {
data: T;
dataFetched: Date;
lastUpdated: Date;
isCached: boolean;
};
// Constants
const CACHE_TTL = 1 * 60 * 60 * 1000; // 1 hour in milliseconds
const REPO_OWNER = 'Longhorn-Developers';
const REPO_NAME = 'UT-Registration-Plus';
export const LONGHORN_DEVELOPERS_ADMINS = [
{ name: 'Sriram Hariharan', role: 'Founder', githubUsername: 'sghsri' },
{ name: 'Elie Soloveichik', role: 'Senior Software Engineer', githubUsername: 'Razboy20' },
{ name: 'Diego Perez', role: 'Senior Software Engineer', githubUsername: 'doprz' },
{ name: 'Lukas Zenick', role: 'Senior Software Engineer', githubUsername: 'Lukas-Zenick' },
{ name: 'Isaiah Rodriguez', role: 'Chief Design Officer', githubUsername: 'IsaDavRod' },
] as const satisfies TeamMember[];
/**
* Represents the GitHub usernames of the admins in the LONGHORN_DEVELOPERS_ADMINS array.
*/
export type LD_ADMIN_GITHUB_USERNAMES = (typeof LONGHORN_DEVELOPERS_ADMINS)[number]['githubUsername'];
/**
* Service for fetching GitHub statistics.
*/
export class GitHubStatsService {
private octokit: Octokit;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private cache: Map<string, CachedData<any>>;
constructor(githubToken?: string) {
this.octokit = githubToken ? new Octokit({ auth: githubToken }) : new Octokit();
this.cache = new Map();
}
private getCachedData<T>(key: string): CachedData<T> | null {
const cachedItem = this.cache.get(key);
if (cachedItem && Date.now() - cachedItem.dataFetched.getTime() < CACHE_TTL) {
return cachedItem;
}
return null;
}
private setCachedData<T>(key: string, data: T): void {
this.cache.set(key, { data, dataFetched: new Date() });
}
private async fetchWithRetry<T>(fetchFn: () => Promise<T>, retries: number = 3, delay: number = 5000): Promise<T> {
try {
return await fetchFn();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
if (retries > 0 && error.status === 202) {
await new Promise(resolve => setTimeout(resolve, delay));
return this.fetchWithRetry(fetchFn, retries - 1, delay);
}
throw error;
}
}
private async fetchContributorStats(): Promise<FetchResult<ContributorStats[]>> {
const cacheKey = `contributor_stats_${REPO_OWNER}_${REPO_NAME}`;
const cachedStats = this.getCachedData<ContributorStats[]>(cacheKey);
if (cachedStats) {
return {
data: cachedStats.data,
dataFetched: cachedStats.dataFetched,
lastUpdated: new Date(),
isCached: true,
};
}
const { data } = await this.fetchWithRetry(() =>
this.octokit.repos.getContributorsStats({
owner: REPO_OWNER,
repo: REPO_NAME,
})
);
if (Array.isArray(data)) {
const fetchResult: FetchResult<ContributorStats[]> = {
data: data as ContributorStats[],
dataFetched: new Date(),
lastUpdated: new Date(),
isCached: false,
};
this.setCachedData(cacheKey, fetchResult.data);
return fetchResult;
}
throw new Error('Invalid response format');
}
private async fetchMergedPRsCount(username: string): Promise<FetchResult<number>> {
const cacheKey = `merged_prs_${username}`;
const cachedCount = this.getCachedData<number>(cacheKey);
if (cachedCount !== null) {
return {
data: cachedCount.data,
dataFetched: cachedCount.dataFetched,
lastUpdated: new Date(),
isCached: true,
};
}
const { data } = await this.octokit.search.issuesAndPullRequests({
q: `org:${REPO_OWNER} author:${username} type:pr is:merged`,
});
const fetchResult: FetchResult<number> = {
data: data.total_count,
dataFetched: new Date(),
lastUpdated: new Date(),
isCached: false,
};
this.setCachedData(cacheKey, fetchResult.data);
return fetchResult;
}
private processContributorStats(stats: ContributorStats): GitHubStats {
return {
commits: stats.total,
linesAdded: stats.weeks.reduce((total, week) => total + week.a, 0),
linesDeleted: stats.weeks.reduce((total, week) => total + week.d, 0),
};
}
public async fetchGitHubStats(options: { includeMergedPRs?: boolean } = {}): Promise<{
adminGitHubStats: Record<string, GitHubStats>;
userGitHubStats: Record<string, GitHubStats>;
contributors: string[];
dataFetched: Date;
lastUpdated: Date;
isCached: boolean;
}> {
const { includeMergedPRs = false } = options;
const adminGitHubStats: Record<string, GitHubStats> = {};
const userGitHubStats: Record<string, GitHubStats> = {};
const contributors: string[] = [];
let oldestDataFetch = new Date();
let newestDataFetch = new Date(0);
let allCached = true;
try {
const contributorStatsResult = await this.fetchContributorStats();
oldestDataFetch = contributorStatsResult.dataFetched;
newestDataFetch = contributorStatsResult.dataFetched;
allCached = contributorStatsResult.isCached;
await Promise.all(
contributorStatsResult.data.map(async stat => {
const { login } = stat.author;
contributors.push(login);
const isAdmin = LONGHORN_DEVELOPERS_ADMINS.some(admin => admin.githubUsername === login);
const statsObject = isAdmin ? adminGitHubStats : userGitHubStats;
statsObject[login] = this.processContributorStats(stat);
if (includeMergedPRs) {
try {
const mergedPRsResult = await this.fetchMergedPRsCount(login);
statsObject[login].mergedPRs = mergedPRsResult.data;
if (mergedPRsResult.dataFetched < oldestDataFetch) {
oldestDataFetch = mergedPRsResult.dataFetched;
}
if (mergedPRsResult.dataFetched > newestDataFetch) {
newestDataFetch = mergedPRsResult.dataFetched;
}
allCached = allCached && mergedPRsResult.isCached;
} catch (error) {
console.error(`Error fetching merged PRs for ${login}:`, error);
statsObject[login].mergedPRs = 0;
}
}
})
);
return {
adminGitHubStats,
userGitHubStats,
contributors,
dataFetched: oldestDataFetch,
lastUpdated: new Date(),
isCached: allCached,
};
} catch (error) {
console.error('Error fetching GitHub stats:', error);
throw error;
}
}
}
// /**
// * Runs an example that fetches GitHub stats using the GitHubStatsService.
// *
// * @returns A promise that resolves when the example is finished running.
// * @throws If there is an error fetching the GitHub stats.
// */
// async function runExample() {
// // Token is now optional
// // const githubToken = process.env.GITHUB_TOKEN;
// const gitHubStatsService = new GitHubStatsService();
// try {
// console.log('Fetching stats without merged PRs...');
// const statsWithoutPRs = await gitHubStatsService.fetchGitHubStats();
// console.log('Data fetched:', statsWithoutPRs.dataFetched.toLocaleString());
// console.log('Last updated:', statsWithoutPRs.lastUpdated.toLocaleString());
// console.log('Is cached:', statsWithoutPRs.isCached);
// console.log(statsWithoutPRs);
// // console.log('\nFetching stats with merged PRs...');
// // const statsWithPRs = await gitHubStatsService.fetchGitHubStats({ includeMergedPRs: true });
// // console.log('Data fetched:', statsWithPRs.dataFetched.toLocaleString());
// // console.log('Last updated:', statsWithPRs.lastUpdated.toLocaleString());
// // console.log('Is cached:', statsWithPRs.isCached);
// // wait 5 seconds
// // await new Promise(resolve => setTimeout(resolve, 5000));
// // console.log('\nFetching stats again (should be cached)...');
// // const cachedStats = await gitHubStatsService.fetchGitHubStats();
// // console.log('Data fetched:', cachedStats.dataFetched.toLocaleString());
// // console.log('Last updated:', cachedStats.lastUpdated.toLocaleString());
// // console.log('Is cached:', cachedStats.isCached);
// } catch (error) {
// console.error('Failed to fetch GitHub stats:', error);
// }
// }
// runExample();