Files
UT-Registration-Plus/src/views/components/settings/Settings.tsx
Kabir Ramzan b732a80eaa feat: add CacheStore for GitHub stats and use names instead of usernames (#405)
* feat: add CacheStore for GitHub stats and use names instead of usernames

* fix: only cache name for successful API queries

* chore: linting

* chore: refactor any with unknown and add jsdocs

* fix: use cached endpoint to avoid rate limiting issues

* fix: code style

* chore: add type assertion

* fix: use correct type

* Revert "fix: use correct type"

This reverts commit 74956c12f3.

* fix: use correct type

* fix: use URL Web API

* fix: add CONTRIBUTORS_API_ROUTE constant

---------

Co-authored-by: Derek Chen <derex1987@gmail.com>
Co-authored-by: doprz <52579214+doprz@users.noreply.github.com>
2024-11-13 12:01:20 -06:00

601 lines
31 KiB
TypeScript

import addCourse from '@pages/background/lib/addCourse';
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 { LargeLogo } 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 useChangelog from '@views/hooks/useChangelog';
import useSchedules from '@views/hooks/useSchedules';
import { CourseCatalogScraper } from '@views/lib/CourseCatalogScraper';
import getCourseTableRows from '@views/lib/getCourseTableRows';
import { GitHubStatsService, LONGHORN_DEVELOPERS_ADMINS, LONGHORN_DEVELOPERS_SWE } from '@views/lib/getGitHubStats';
import { SiteSupport } from '@views/lib/getSiteSupport';
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 { useMigrationDialog } from '../common/MigrationDialog';
// 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);
const showMigrationDialog = useMigrationDialog();
// 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();
const handleChangelogOnClick = useChangelog();
useEffect(() => {
const fetchGitHubStats = async () => {
try {
const stats = await gitHubStatsService.fetchGitHubStats();
setGitHubStats(stats);
} catch (error) {
console.warn('Error fetching GitHub stats:', error);
}
};
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>
),
});
};
// todo: move into a util/shared place, rather than specifically in settings
const handleAddCourseByUrl = async () => {
// todo: Use a proper modal instead of a prompt
// eslint-disable-next-line no-alert
const link: string | null = prompt('Enter course link');
// Exit if the user cancels the prompt
if (link === null) return;
try {
let response: Response;
try {
response = await fetch(link);
} catch (e) {
// eslint-disable-next-line no-alert
alert(`Failed to fetch url '${link}'`);
return;
}
const text = await response.text();
const doc = new DOMParser().parseFromString(text, 'text/html');
const scraper = new CourseCatalogScraper(SiteSupport.COURSE_CATALOG_DETAILS, doc, link);
const tableRows = getCourseTableRows(doc);
const courses = scraper.scrape(tableRows, false);
if (courses.length === 1) {
const description = scraper.getDescription(doc);
const row = courses[0]!;
const course = row.course!;
course.description = description;
// console.log(course);
if (activeSchedule.courses.every(c => c.uniqueId !== course.uniqueId)) {
console.log('adding course');
addCourse(activeSchedule.id, course);
} else {
console.log('course already exists');
}
} else {
console.log(courses);
}
} catch (error) {
console.error('Error scraping course:', error);
}
};
const [devMode, toggleDevMode] = useDevMode(10);
if (devMode) {
return <DevMode />;
}
return (
<div>
<header 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'>
<LargeLogo />
<Divider className='mx-2 self-center md:mx-4' size='2.5rem' orientation='vertical' />
<Text variant='h1' className='flex-1 text-ut-burntorange'>
UTRP SETTINGS & CREDITS PAGE
</Text>
<div className='hidden flex-row items-center justify-end gap-6 screenshot:hidden lg:flex'>
<Button variant='single' color='theme-black' onClick={handleChangelogOnClick}>
<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>
</Button>
<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='theme-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'>
LAST UPDATED: {getUpdatedAtDateTimeString(activeSchedule.updatedAt)}
</Text>
</div>
<Text
variant='h2-course'
className={clsx('text-center text-theme-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 space-y-4'>
<h2 className='mb-4 text-xl text-ut-black font-semibold' onClick={toggleDevMode}>
Developer Mode
</h2>
<Button variant='filled' color='ut-black' onClick={handleAddCourseByUrl}>
Add course by link
</Button>
<Button variant='filled' color='ut-burntorange' onClick={showMigrationDialog}>
Show Migration Dialog
</Button>
</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-theme-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 CONTRIBUTORS</h2>
<div className='grid grid-cols-2 gap-4 2xl:grid-cols-4 md:grid-cols-3 xl:grid-cols-3'>
{LONGHORN_DEVELOPERS_SWE.sort(
(a, b) =>
(githubStats?.userGitHubStats[b.githubUsername]?.commits ?? 0) -
(githubStats?.userGitHubStats[a.githubUsername]?.commits ?? 0)
).map(swe => (
<div
key={swe.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/${swe.githubUsername}`, '_blank')
}
>
{swe.name}
</Text>
<p className='text-sm text-gray-600'>{swe.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.userGitHubStats[swe.githubUsername]?.mergedPRs}
</p>
)}
<p className='text-xs'>
Commits: {githubStats.userGitHubStats[swe.githubUsername]?.commits}
</p>
<p className='text-xs text-ut-green'>
{githubStats.userGitHubStats[swe.githubUsername]?.linesAdded} ++
</p>
<p className='text-xs text-theme-red'>
{githubStats.userGitHubStats[swe.githubUsername]?.linesDeleted} --
</p>
</div>
)}
</div>
))}
{githubStats &&
Object.keys(githubStats.userGitHubStats)
.filter(
username =>
!LONGHORN_DEVELOPERS_ADMINS.some(
admin => admin.githubUsername === username
) && !LONGHORN_DEVELOPERS_SWE.some(swe => swe.githubUsername === username)
)
.sort(
(a, b) =>
(githubStats.userGitHubStats[b]?.commits ?? 0) -
(githubStats.userGitHubStats[a]?.commits ?? 0)
)
.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')}
>
{githubStats.names[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-theme-red'>
{githubStats.userGitHubStats[username]?.linesDeleted} --
</p>
</div>
)}
</div>
))}
</div>
</section>
</section>
</div>
</div>
);
}