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:
@@ -27,6 +27,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^2.1.8",
|
"@headlessui/react": "^2.1.8",
|
||||||
"@hello-pangea/dnd": "^17.0.0",
|
"@hello-pangea/dnd": "^17.0.0",
|
||||||
|
"@octokit/rest": "^21.0.2",
|
||||||
"@unocss/vite": "^0.63.2",
|
"@unocss/vite": "^0.63.2",
|
||||||
"@vitejs/plugin-react": "^4.3.2",
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
"chrome-extension-toolkit": "^0.0.54",
|
"chrome-extension-toolkit": "^0.0.54",
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
"highcharts-react-official": "^3.2.1",
|
"highcharts-react-official": "^3.2.1",
|
||||||
"html-to-image": "^1.11.11",
|
"html-to-image": "^1.11.11",
|
||||||
"husky": "^9.1.6",
|
"husky": "^9.1.6",
|
||||||
|
"kc-dabr-wasm": "^0.1.2",
|
||||||
"nanoid": "^5.0.7",
|
"nanoid": "^5.0.7",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
"@commitlint/types": "^19.5.0",
|
"@commitlint/types": "^19.5.0",
|
||||||
"@crxjs/vite-plugin": "2.0.0-beta.21",
|
"@crxjs/vite-plugin": "2.0.0-beta.21",
|
||||||
"@iconify-json/bi": "^1.2.0",
|
"@iconify-json/bi": "^1.2.0",
|
||||||
|
"@iconify-json/iconoir": "^1.2.1",
|
||||||
"@iconify-json/material-symbols": "^1.2.2",
|
"@iconify-json/material-symbols": "^1.2.2",
|
||||||
"@iconify-json/ri": "^1.2.0",
|
"@iconify-json/ri": "^1.2.0",
|
||||||
"@storybook/addon-designs": "^8.0.3",
|
"@storybook/addon-designs": "^8.0.3",
|
||||||
|
|||||||
1386
pnpm-lock.yaml
generated
1386
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
src/assets/LD-icon.png
Normal file
BIN
src/assets/LD-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
@@ -1,4 +1,5 @@
|
|||||||
import { DevStore } from '@shared/storage/DevStore';
|
import { DevStore } from '@shared/storage/DevStore';
|
||||||
|
import useKC_DABR_WASM from 'kc-dabr-wasm';
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
@@ -77,6 +78,7 @@ function DevDashboard() {
|
|||||||
const [localStorage, setLocalStorage] = React.useState<Record<string, unknown>>({});
|
const [localStorage, setLocalStorage] = React.useState<Record<string, unknown>>({});
|
||||||
const [syncStorage, setSyncStorage] = React.useState<Record<string, unknown>>({});
|
const [syncStorage, setSyncStorage] = React.useState<Record<string, unknown>>({});
|
||||||
const [sessionStorage, setSessionStorage] = React.useState<Record<string, unknown>>({});
|
const [sessionStorage, setSessionStorage] = React.useState<Record<string, unknown>>({});
|
||||||
|
useKC_DABR_WASM();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onVisibilityChange = () => {
|
const onVisibilityChange = () => {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
||||||
|
|
||||||
|
import createSchedule from './createSchedule';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes a schedule with the specified name.
|
* Deletes a schedule with the specified name.
|
||||||
*
|
*
|
||||||
@@ -32,3 +34,14 @@ export default async function deleteSchedule(scheduleId: string): Promise<string
|
|||||||
}
|
}
|
||||||
return undefined;
|
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');
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Calendar from '@views/components/calendar/Calendar';
|
|||||||
import DialogProvider from '@views/components/common/DialogProvider/DialogProvider';
|
import DialogProvider from '@views/components/common/DialogProvider/DialogProvider';
|
||||||
import ExtensionRoot from '@views/components/common/ExtensionRoot/ExtensionRoot';
|
import ExtensionRoot from '@views/components/common/ExtensionRoot/ExtensionRoot';
|
||||||
import { MessageListener } from 'chrome-extension-toolkit';
|
import { MessageListener } from 'chrome-extension-toolkit';
|
||||||
|
import useKC_DABR_WASM from 'kc-dabr-wasm';
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -10,6 +11,7 @@ import React, { useEffect } from 'react';
|
|||||||
* @returns entire page
|
* @returns entire page
|
||||||
*/
|
*/
|
||||||
export default function CalendarMain() {
|
export default function CalendarMain() {
|
||||||
|
useKC_DABR_WASM();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tabInfoListener = new MessageListener<TabInfoMessages>({
|
const tabInfoListener = new MessageListener<TabInfoMessages>({
|
||||||
getTabInfo: ({ sendResponse }) => {
|
getTabInfo: ({ sendResponse }) => {
|
||||||
|
|||||||
22
src/pages/options/Settings.tsx
Normal file
22
src/pages/options/Settings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
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 />);
|
||||||
|
|||||||
@@ -3,20 +3,44 @@ import { createSyncStore, debugStore } from 'chrome-extension-toolkit';
|
|||||||
/**
|
/**
|
||||||
* A store that is used for storing user options
|
* A store that is used for storing user options
|
||||||
*/
|
*/
|
||||||
interface IOptionsStore {
|
export interface IOptionsStore {
|
||||||
/** whether we should automatically highlight conflicts on the course schedule page */
|
/** whether we should enable course status chips (indicator for waitlisted, cancelled, and closed courses) */
|
||||||
shouldHighlightConflicts: boolean;
|
enableCourseStatusChips: boolean;
|
||||||
/** whether we should automatically scroll to load more courses on the course schedule page (without having to click next) */
|
|
||||||
shouldScrollToLoad: 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>({
|
export const OptionsStore = createSyncStore<IOptionsStore>({
|
||||||
shouldHighlightConflicts: true,
|
enableCourseStatusChips: false,
|
||||||
shouldScrollToLoad: true,
|
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
|
// Clothing retailer right
|
||||||
|
|
||||||
debugStore({ OptionsStore });
|
debugStore({ OptionsStore });
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
export const enableCourseStatusChips: boolean = false;
|
|
||||||
export const constenableSettingsPage: boolean = false;
|
|
||||||
export const enableCourseRefreshing: boolean = false;
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import Settings from '@views/components/Settings';
|
import Settings from '@views/components/settings/Settings';
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Components/Common/Settings',
|
title: 'Components/Common/Settings',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import splashText from '@assets/insideJokes';
|
import splashText from '@assets/insideJokes';
|
||||||
import { background } from '@shared/messages';
|
import { background } from '@shared/messages';
|
||||||
|
import { initSettings, OptionsStore } from '@shared/storage/OptionsStore';
|
||||||
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
||||||
import { enableCourseRefreshing, enableCourseStatusChips } from '@shared/util/experimental';
|
|
||||||
import Divider from '@views/components/common/Divider';
|
import Divider from '@views/components/common/Divider';
|
||||||
import ExtensionRoot from '@views/components/common/ExtensionRoot/ExtensionRoot';
|
import ExtensionRoot from '@views/components/common/ExtensionRoot/ExtensionRoot';
|
||||||
import List from '@views/components/common/List';
|
import List from '@views/components/common/List';
|
||||||
@@ -10,6 +10,7 @@ import useSchedules, { getActiveSchedule, replaceSchedule, switchSchedule } from
|
|||||||
import { getUpdatedAtDateTimeString } from '@views/lib/getUpdatedAtDateTimeString';
|
import { getUpdatedAtDateTimeString } from '@views/lib/getUpdatedAtDateTimeString';
|
||||||
import { openTabFromContentScript } from '@views/lib/openNewTabFromContentScript';
|
import { openTabFromContentScript } from '@views/lib/openNewTabFromContentScript';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
import useKC_DABR_WASM from 'kc-dabr-wasm';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import CalendarIcon from '~icons/material-symbols/calendar-month';
|
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.
|
* This component displays the main schedule, courses, and options buttons.
|
||||||
*/
|
*/
|
||||||
export default function PopupMain(): JSX.Element {
|
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 [activeSchedule, schedules] = useSchedules();
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [funny, setFunny] = useState<string>('');
|
const [funny, setFunny] = useState<string>('');
|
||||||
@@ -139,7 +166,7 @@ export default function PopupMain(): JSX.Element {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{enableCourseRefreshing && (
|
{enableDataRefreshing && (
|
||||||
<div className='inline-flex items-center self-center gap-1'>
|
<div className='inline-flex items-center self-center gap-1'>
|
||||||
<Text variant='mini' className='text-ut-gray !font-normal'>
|
<Text variant='mini' className='text-ut-gray !font-normal'>
|
||||||
DATA LAST UPDATED: {getUpdatedAtDateTimeString(activeSchedule.updatedAt)}
|
DATA LAST UPDATED: {getUpdatedAtDateTimeString(activeSchedule.updatedAt)}
|
||||||
|
|||||||
@@ -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>;
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
|
import { initSettings, OptionsStore } from '@shared/storage/OptionsStore';
|
||||||
import type { StatusType } from '@shared/types/Course';
|
import type { StatusType } from '@shared/types/Course';
|
||||||
import { Status } from '@shared/types/Course';
|
import { Status } from '@shared/types/Course';
|
||||||
import type { CourseColors } from '@shared/types/ThemeColors';
|
import type { CourseColors } from '@shared/types/ThemeColors';
|
||||||
import { pickFontColor } from '@shared/util/colors';
|
import { pickFontColor } from '@shared/util/colors';
|
||||||
import { enableCourseStatusChips } from '@shared/util/experimental';
|
|
||||||
import Text from '@views/components/common/Text/Text';
|
import Text from '@views/components/common/Text/Text';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import ClosedIcon from '~icons/material-symbols/lock';
|
import ClosedIcon from '~icons/material-symbols/lock';
|
||||||
import WaitlistIcon from '~icons/material-symbols/timelapse';
|
import WaitlistIcon from '~icons/material-symbols/timelapse';
|
||||||
@@ -43,6 +43,21 @@ export default function CalendarCourseCell({
|
|||||||
className,
|
className,
|
||||||
onClick,
|
onClick,
|
||||||
}: CalendarCourseCellProps): JSX.Element {
|
}: 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;
|
let rightIcon: React.ReactNode | null = null;
|
||||||
if (enableCourseStatusChips) {
|
if (enableCourseStatusChips) {
|
||||||
if (status === Status.WAITLISTED) {
|
if (status === Status.WAITLISTED) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import { initSettings, OptionsStore } from '@shared/storage/OptionsStore';
|
||||||
import { Status } from '@shared/types/Course';
|
import { Status } from '@shared/types/Course';
|
||||||
import { enableCourseRefreshing, enableCourseStatusChips } from '@shared/util/experimental';
|
|
||||||
import { Button } from '@views/components/common/Button';
|
import { Button } from '@views/components/common/Button';
|
||||||
import CourseStatus from '@views/components/common/CourseStatus';
|
import CourseStatus from '@views/components/common/CourseStatus';
|
||||||
import Divider from '@views/components/common/Divider';
|
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 useSchedules from '@views/hooks/useSchedules';
|
||||||
import { getUpdatedAtDateTimeString } from '@views/lib/getUpdatedAtDateTimeString';
|
import { getUpdatedAtDateTimeString } from '@views/lib/getUpdatedAtDateTimeString';
|
||||||
import { openTabFromContentScript } from '@views/lib/openNewTabFromContentScript';
|
import { openTabFromContentScript } from '@views/lib/openNewTabFromContentScript';
|
||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import MenuIcon from '~icons/material-symbols/menu';
|
import MenuIcon from '~icons/material-symbols/menu';
|
||||||
import RefreshIcon from '~icons/material-symbols/refresh';
|
import RefreshIcon from '~icons/material-symbols/refresh';
|
||||||
@@ -32,8 +32,33 @@ interface CalendarHeaderProps {
|
|||||||
* @returns The JSX element representing the calendar header.
|
* @returns The JSX element representing the calendar header.
|
||||||
*/
|
*/
|
||||||
export default function CalendarHeader({ onSidebarToggle }: CalendarHeaderProps): JSX.Element {
|
export default function CalendarHeader({ onSidebarToggle }: CalendarHeaderProps): JSX.Element {
|
||||||
|
const [enableCourseStatusChips, setEnableCourseStatusChips] = useState<boolean>(false);
|
||||||
|
const [enableDataRefreshing, setEnableDataRefreshing] = useState<boolean>(false);
|
||||||
|
|
||||||
const [activeSchedule] = useSchedules();
|
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 (
|
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'>
|
<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
|
<Button
|
||||||
@@ -51,7 +76,7 @@ export default function CalendarHeader({ onSidebarToggle }: CalendarHeaderProps)
|
|||||||
totalHours={activeSchedule.hours}
|
totalHours={activeSchedule.hours}
|
||||||
totalCourses={activeSchedule.courses.length}
|
totalCourses={activeSchedule.courses.length}
|
||||||
/>
|
/>
|
||||||
{enableCourseRefreshing && (
|
{enableDataRefreshing && (
|
||||||
<div className='flex items-center gap-1 screenshot:hidden'>
|
<div className='flex items-center gap-1 screenshot:hidden'>
|
||||||
<Text variant='mini' className='text-nowrap text-ut-gray font-normal!'>
|
<Text variant='mini' className='text-nowrap text-ut-gray font-normal!'>
|
||||||
DATA LAST UPDATED: {getUpdatedAtDateTimeString(activeSchedule.updatedAt)}
|
DATA LAST UPDATED: {getUpdatedAtDateTimeString(activeSchedule.updatedAt)}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import type { DraggableProvidedDragHandleProps } from '@hello-pangea/dnd';
|
import type { DraggableProvidedDragHandleProps } from '@hello-pangea/dnd';
|
||||||
import { background } from '@shared/messages';
|
import { background } from '@shared/messages';
|
||||||
|
import { initSettings, OptionsStore } from '@shared/storage/OptionsStore';
|
||||||
import type { Course } from '@shared/types/Course';
|
import type { Course } from '@shared/types/Course';
|
||||||
import { Status } from '@shared/types/Course';
|
import { Status } from '@shared/types/Course';
|
||||||
import type { CourseColors } from '@shared/types/ThemeColors';
|
import type { CourseColors } from '@shared/types/ThemeColors';
|
||||||
import { pickFontColor } from '@shared/util/colors';
|
import { pickFontColor } from '@shared/util/colors';
|
||||||
import { enableCourseStatusChips } from '@shared/util/experimental';
|
|
||||||
import { StatusIcon } from '@shared/util/icons';
|
import { StatusIcon } from '@shared/util/icons';
|
||||||
import Text from '@views/components/common/Text/Text';
|
import Text from '@views/components/common/Text/Text';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import DragIndicatorIcon from '~icons/material-symbols/drag-indicator';
|
import DragIndicatorIcon from '~icons/material-symbols/drag-indicator';
|
||||||
|
|
||||||
@@ -33,6 +33,21 @@ export default function PopupCourseBlock({
|
|||||||
colors,
|
colors,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
}: PopupCourseBlockProps): JSX.Element {
|
}: 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
|
// text-white or text-black based on secondaryColor
|
||||||
const fontColor = pickFontColor(colors.primaryColor);
|
const fontColor = pickFontColor(colors.primaryColor);
|
||||||
const formattedUniqueId = course.uniqueId.toString().padStart(5, '0');
|
const formattedUniqueId = course.uniqueId.toString().padStart(5, '0');
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const SwitchButton = ({ isChecked = true, onChange }: ToggleSwitchProps): JSX.El
|
|||||||
checked={enabled}
|
checked={enabled}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className={`${enabled ? 'bg-[#579D42]' : 'bg-gray-400'}
|
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
|
<span
|
||||||
className={`${enabled ? 'translate-x-6' : 'translate-x-1'}
|
className={`${enabled ? 'translate-x-6' : 'translate-x-1'}
|
||||||
|
|||||||
@@ -86,3 +86,10 @@
|
|||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.isConflictNoLineThrough:not(.inActiveSchedule) {
|
||||||
|
> *:not(td:last-child) {
|
||||||
|
color: colors.$speedway_brick;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { initSettings, OptionsStore } from '@shared/storage/OptionsStore';
|
||||||
import type { Course, ScrapedRow } from '@shared/types/Course';
|
import type { Course, ScrapedRow } from '@shared/types/Course';
|
||||||
import type { UserSchedule } from '@shared/types/UserSchedule';
|
import type { UserSchedule } from '@shared/types/UserSchedule';
|
||||||
import ConflictsWithWarning from '@views/components/common/ConflictsWithWarning';
|
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
|
// the courses in the active schedule that conflict with the course for this row
|
||||||
const [conflicts, setConflicts] = useState<Course[]>([]);
|
const [conflicts, setConflicts] = useState<Course[]>([]);
|
||||||
|
const [highlightConflicts, setHighlightConflicts] = useState<boolean>(false);
|
||||||
|
|
||||||
const { element, course } = row;
|
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(() => {
|
useEffect(() => {
|
||||||
element.classList.add(styles.row!);
|
element.classList.add(styles.row!);
|
||||||
element.classList.add('group');
|
element.classList.add('group');
|
||||||
@@ -72,14 +90,23 @@ export default function TableRow({ row, isSelected, activeSchedule, onClick }: P
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear conflict styling
|
||||||
|
element.classList.remove(styles.isConflict!);
|
||||||
|
element.classList.remove(styles.isConflictNoLineThrough!);
|
||||||
|
|
||||||
|
if (highlightConflicts) {
|
||||||
element.classList[conflicts.length ? 'add' : 'remove'](styles.isConflict!);
|
element.classList[conflicts.length ? 'add' : 'remove'](styles.isConflict!);
|
||||||
|
} else {
|
||||||
|
element.classList[conflicts.length ? 'add' : 'remove'](styles.isConflictNoLineThrough!);
|
||||||
|
}
|
||||||
|
|
||||||
setConflicts(conflicts);
|
setConflicts(conflicts);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
element.classList.remove(styles.isConflict!);
|
element.classList.remove(styles.isConflict!);
|
||||||
setConflicts([]);
|
setConflicts([]);
|
||||||
};
|
};
|
||||||
}, [activeSchedule, course, element.classList]);
|
}, [activeSchedule, course, element.classList, highlightConflicts]);
|
||||||
|
|
||||||
if (!container) {
|
if (!container) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import Link from '@views/components/common/Link';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Renders the DevMode component.
|
||||||
*
|
*
|
||||||
|
* @returns The rendered DevMode component.
|
||||||
*/
|
*/
|
||||||
export default function App() {
|
export default function DevMode() {
|
||||||
return (
|
return (
|
||||||
<ExtensionRoot>
|
<ExtensionRoot>
|
||||||
<div className='text-base'>
|
<div className='text-base'>
|
||||||
30
src/views/components/settings/Preview.tsx
Normal file
30
src/views/components/settings/Preview.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
487
src/views/components/settings/Settings.tsx
Normal file
487
src/views/components/settings/Settings.tsx
Normal 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's time and location in the extension'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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
266
src/views/lib/getGitHubStats.ts
Normal file
266
src/views/lib/getGitHubStats.ts
Normal 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();
|
||||||
Reference in New Issue
Block a user