Merge branch 'Longhorn-Developers:main' into main
This commit is contained in:
Binary file not shown.
@@ -22,6 +22,8 @@ export type CourseSQLRow = {
|
|||||||
Course_Number: string;
|
Course_Number: string;
|
||||||
Course_Title: string;
|
Course_Title: string;
|
||||||
Course_Full_Title: string;
|
Course_Full_Title: string;
|
||||||
|
Instructor_First: string | null;
|
||||||
|
Instructor_Last: string | null;
|
||||||
A: number;
|
A: number;
|
||||||
A_Minus: number;
|
A_Minus: number;
|
||||||
B_Plus: number;
|
B_Plus: number;
|
||||||
|
|||||||
18
src/stories/components/LogoIcon.stories.tsx
Normal file
18
src/stories/components/LogoIcon.stories.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { LargeLogo, SmallLogo } from '@views/components/common/LogoIcon';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Components/Common/Logo',
|
||||||
|
component: SmallLogo,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
} satisfies Meta<typeof SmallLogo>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Small: Story = {};
|
||||||
|
|
||||||
|
export const Large: Story = {
|
||||||
|
render: args => <LargeLogo {...args} />,
|
||||||
|
};
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import Spinner from '@views/components/common/Spinner/Spinner';
|
import Spinner from '@views/components/common/Spinner';
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Components/Common/Spinner',
|
title: 'Components/Common/Spinner',
|
||||||
component: Spinner,
|
component: Spinner,
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
argTypes: {},
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
} satisfies Meta<typeof Spinner>;
|
} satisfies Meta<typeof Spinner>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
@@ -57,7 +57,7 @@ export default function CalendarCourseCell({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'h-full w-0 flex justify-center rounded p-2 cursor-pointer screenshot:p-1.5',
|
'h-full w-0 flex justify-center rounded p-2 cursor-pointer screenshot:p-1.5 hover:shadow-md transition-shadow-100 ease-out',
|
||||||
{
|
{
|
||||||
'min-w-full': timeAndLocation,
|
'min-w-full': timeAndLocation,
|
||||||
'w-full': !timeAndLocation,
|
'w-full': !timeAndLocation,
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export default function CalendarHeader({ onSidebarToggle }: CalendarHeaderProps)
|
|||||||
totalCourses={activeSchedule.courses.length}
|
totalCourses={activeSchedule.courses.length}
|
||||||
/>
|
/>
|
||||||
<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)}
|
||||||
</Text>
|
</Text>
|
||||||
<button className='inline-block h-4 w-4 bg-transparent p-0 btn'>
|
<button className='inline-block h-4 w-4 bg-transparent p-0 btn'>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function SmallLogo({ className }: { className?: string }): JSX.Element {
|
|||||||
return (
|
return (
|
||||||
<div className={clsx('flex items-center gap-2', className)}>
|
<div className={clsx('flex items-center gap-2', className)}>
|
||||||
<LogoIcon />
|
<LogoIcon />
|
||||||
<div className='flex flex-col text-lg font-medium leading-[1em]'>
|
<div className='flex flex-col text-lg font-medium leading-[1em] mt-1'>
|
||||||
<p className='text-nowrap text-ut-burntorange'>UT Registration</p>
|
<p className='text-nowrap text-ut-burntorange'>UT Registration</p>
|
||||||
<p className='text-ut-burntorange'>
|
<p className='text-ut-burntorange'>
|
||||||
Plus{' '}
|
Plus{' '}
|
||||||
@@ -51,7 +51,7 @@ export function LargeLogo({ className }: { className?: string }): JSX.Element {
|
|||||||
return (
|
return (
|
||||||
<div className={clsx('flex items-center gap-2', className)}>
|
<div className={clsx('flex items-center gap-2', className)}>
|
||||||
<LogoIcon className='h-12 w-12' />
|
<LogoIcon className='h-12 w-12' />
|
||||||
<div className='hidden flex-col text-[1.35rem] font-medium leading-[1em] md:flex screenshot:flex'>
|
<div className='hidden flex-col text-[1.35rem] font-medium leading-[1em] md:flex screenshot:flex mt-1'>
|
||||||
<p className='text-nowrap text-ut-burntorange'>UT Registration</p>
|
<p className='text-nowrap text-ut-burntorange'>UT Registration</p>
|
||||||
<p className='text-ut-burntorange'>
|
<p className='text-ut-burntorange'>
|
||||||
Plus{' '}
|
Plus{' '}
|
||||||
|
|||||||
21
src/views/components/common/Spinner.tsx
Normal file
21
src/views/components/common/Spinner.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { SVGProps } from 'react';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A simple spinner component that can be used to indicate loading.
|
||||||
|
*/
|
||||||
|
export default function Spinner({ className, ...rest }: SVGProps<SVGSVGElement>): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`will-change-transform animate-spin w-16 h-16 text-ut-orange ${className ?? ''}`}
|
||||||
|
style={{ animationDuration: '225ms' }}
|
||||||
|
>
|
||||||
|
<svg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' {...rest}>
|
||||||
|
<path
|
||||||
|
fill='currentColor'
|
||||||
|
d='M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
@use 'src/views/styles/colors.module.scss';
|
|
||||||
|
|
||||||
$spinner-border-width: 10px;
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
border: 1px solid colors.$charcoal;
|
|
||||||
border-width: $spinner-border-width;
|
|
||||||
border-top: $spinner-border-width solid colors.$tangerine;
|
|
||||||
margin: 0 auto 15px auto;
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 60px;
|
|
||||||
height: 60px;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
0% {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import clsx from 'clsx';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import styles from './Spinner.module.scss';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
testId?: string;
|
|
||||||
className?: string;
|
|
||||||
style?: React.CSSProperties;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A simple spinner component that can be used to indicate loading.
|
|
||||||
*/
|
|
||||||
export default function Spinner({ className, testId, style }: Props): JSX.Element {
|
|
||||||
return <div data-testid={testId} style={style} className={clsx(styles.spinner, className)} />;
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Course } from '@shared/types/Course';
|
import type { Course } from '@shared/types/Course';
|
||||||
import Spinner from '@views/components/common/Spinner/Spinner';
|
import Spinner from '@views/components/common/Spinner';
|
||||||
import Text from '@views/components/common/Text/Text';
|
import Text from '@views/components/common/Text/Text';
|
||||||
import { CourseCatalogScraper } from '@views/lib/CourseCatalogScraper';
|
import { CourseCatalogScraper } from '@views/lib/CourseCatalogScraper';
|
||||||
import { SiteSupport } from '@views/lib/getSiteSupport';
|
import { SiteSupport } from '@views/lib/getSiteSupport';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Course } from '@shared/types/Course';
|
import type { Course } from '@shared/types/Course';
|
||||||
import type { Distribution, LetterGrade } from '@shared/types/Distribution';
|
import type { Distribution, LetterGrade } from '@shared/types/Distribution';
|
||||||
import { extendedColors } from '@shared/types/ThemeColors';
|
import { extendedColors } from '@shared/types/ThemeColors';
|
||||||
import Spinner from '@views/components/common/Spinner/Spinner';
|
import Spinner from '@views/components/common/Spinner';
|
||||||
import Text from '@views/components/common/Text/Text';
|
import Text from '@views/components/common/Text/Text';
|
||||||
import {
|
import {
|
||||||
NoDataError,
|
NoDataError,
|
||||||
@@ -52,13 +52,14 @@ const GRADE_COLORS = {
|
|||||||
*/
|
*/
|
||||||
export default function GradeDistribution({ course }: GradeDistributionProps): JSX.Element {
|
export default function GradeDistribution({ course }: GradeDistributionProps): JSX.Element {
|
||||||
const [semester, setSemester] = useState('Aggregate');
|
const [semester, setSemester] = useState('Aggregate');
|
||||||
const [distributions, setDistributions] = useState<Record<string, Distribution>>({});
|
type Distributions = Record<string, { data: Distribution; instructorIncluded: boolean }>;
|
||||||
|
const [distributions, setDistributions] = useState<Distributions>({});
|
||||||
const [status, setStatus] = useState<DataStatusType>(DataStatus.LOADING);
|
const [status, setStatus] = useState<DataStatusType>(DataStatus.LOADING);
|
||||||
const ref = useRef<HighchartsReact.RefObject>(null);
|
const ref = useRef<HighchartsReact.RefObject>(null);
|
||||||
|
|
||||||
const chartData = useMemo(() => {
|
const chartData = useMemo(() => {
|
||||||
if (status === DataStatus.FOUND && distributions[semester]) {
|
if (status === DataStatus.FOUND && distributions[semester]) {
|
||||||
return Object.entries(distributions[semester]!).map(([grade, count]) => ({
|
return Object.entries(distributions[semester]!.data).map(([grade, count]) => ({
|
||||||
y: count,
|
y: count,
|
||||||
color: GRADE_COLORS[grade as LetterGrade],
|
color: GRADE_COLORS[grade as LetterGrade],
|
||||||
}));
|
}));
|
||||||
@@ -69,8 +70,11 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchInitialData = async () => {
|
const fetchInitialData = async () => {
|
||||||
try {
|
try {
|
||||||
const [aggregateDist, semesters] = await queryAggregateDistribution(course);
|
const [aggregateDist, semesters, instructorIncludedAggregate] =
|
||||||
const initialDistributions: Record<string, Distribution> = { Aggregate: aggregateDist };
|
await queryAggregateDistribution(course);
|
||||||
|
const initialDistributions: Distributions = {
|
||||||
|
Aggregate: { data: aggregateDist, instructorIncluded: instructorIncludedAggregate },
|
||||||
|
};
|
||||||
const semesterPromises = semesters.map(semester => querySemesterDistribution(course, semester));
|
const semesterPromises = semesters.map(semester => querySemesterDistribution(course, semester));
|
||||||
const semesterDistributions = await Promise.allSettled(semesterPromises);
|
const semesterDistributions = await Promise.allSettled(semesterPromises);
|
||||||
semesters.forEach((semester, i) => {
|
semesters.forEach((semester, i) => {
|
||||||
@@ -81,7 +85,11 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (distributionResult.status === 'fulfilled') {
|
if (distributionResult.status === 'fulfilled') {
|
||||||
initialDistributions[`${semester.season} ${semester.year}`] = distributionResult.value;
|
const [distribution, instructorIncluded] = distributionResult.value;
|
||||||
|
initialDistributions[`${semester.season} ${semester.year}`] = {
|
||||||
|
data: distribution,
|
||||||
|
instructorIncluded,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
setDistributions(initialDistributions);
|
setDistributions(initialDistributions);
|
||||||
@@ -236,6 +244,14 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
{distributions[semester] && !distributions[semester]!.instructorIncluded && (
|
||||||
|
<div className='mt-3 flex flex-wrap content-center items-center self-stretch justify-center gap-3'>
|
||||||
|
<Text variant='mini' className='text-theme-red italic!'>
|
||||||
|
Instructor-specific data is not available for this course
|
||||||
|
{semester !== 'Aggregate' && ` for ${semester}`}, showing course-wide data instead
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<HighchartsReact ref={ref} highcharts={Highcharts} options={chartOptions} />
|
<HighchartsReact ref={ref} highcharts={Highcharts} options={chartOptions} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export default function HeadingAndActions({ course, activeSchedule, onClose }: H
|
|||||||
<Link
|
<Link
|
||||||
key={instructor.fullName}
|
key={instructor.fullName}
|
||||||
variant='h4'
|
variant='h4'
|
||||||
href={instructor.getDirectoryUrl()}
|
href={`https://utdirect.utexas.edu/apps/student/coursedocs/nlogon/?year=&semester=&course_title=&unique=&instructor_first=${instructor.firstName}&instructor_last=${instructor.lastName}&course_type=In+Residence&search=Search`}
|
||||||
className='link'
|
className='link'
|
||||||
>
|
>
|
||||||
{getInstructorFullName(instructor)}
|
{getInstructorFullName(instructor)}
|
||||||
|
|||||||
@@ -3,28 +3,27 @@ import type { CourseSQLRow, Distribution } from '@shared/types/Distribution';
|
|||||||
|
|
||||||
import { initializeDB } from './initializeDB';
|
import { initializeDB } from './initializeDB';
|
||||||
|
|
||||||
// TODO: in the future let's maybe refactor this to be reactive to the items in the db rather than being explicit
|
|
||||||
const allTables = [
|
|
||||||
'grade_distributions_2019_2020',
|
|
||||||
'grade_distributions_2020_2021',
|
|
||||||
'grade_distributions_2021_2022',
|
|
||||||
'grade_distributions_2022_2023',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* fetches the aggregate distribution of grades for a given course from the course db, and the semesters that we have data for
|
* fetches the aggregate distribution of grades for a given course from the course db, and the semesters that we have data for
|
||||||
* @param course the course to fetch the distribution for
|
* @param course the course to fetch the distribution for
|
||||||
* @returns a Distribution object containing the distribution of grades for the course, and
|
* @returns a Distribution object containing the distribution of grades for the course, and
|
||||||
* an array of semesters that we have the distribution for
|
* an array of semesters that we have the distribution for
|
||||||
*/
|
*/
|
||||||
export async function queryAggregateDistribution(course: Course): Promise<[Distribution, Semester[]]> {
|
export async function queryAggregateDistribution(course: Course): Promise<[Distribution, Semester[], boolean]> {
|
||||||
const db = await initializeDB();
|
const db = await initializeDB();
|
||||||
const query = generateQuery(course, null);
|
const query = generateQuery(course, null, true);
|
||||||
|
|
||||||
|
let res = db.exec(query)?.[0];
|
||||||
|
let instructorIncluded = true;
|
||||||
|
if (!res?.columns?.length) {
|
||||||
|
instructorIncluded = false;
|
||||||
|
const queryWithoutInstructor = generateQuery(course, null, false);
|
||||||
|
res = db.exec(queryWithoutInstructor)?.[0];
|
||||||
|
|
||||||
const res = db.exec(query)?.[0];
|
|
||||||
if (!res?.columns?.length) {
|
if (!res?.columns?.length) {
|
||||||
throw new NoDataError(course);
|
throw new NoDataError(course);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const row: Required<CourseSQLRow> = {} as Required<CourseSQLRow>;
|
const row: Required<CourseSQLRow> = {} as Required<CourseSQLRow>;
|
||||||
for (let i = 0; i < res.columns.length; i++) {
|
for (let i = 0; i < res.columns.length; i++) {
|
||||||
@@ -79,7 +78,7 @@ export async function queryAggregateDistribution(course: Course): Promise<[Distr
|
|||||||
semesters.push({ year: parseInt(year, 10), season: season as Semester['season'] });
|
semesters.push({ year: parseInt(year, 10), season: season as Semester['season'] });
|
||||||
});
|
});
|
||||||
|
|
||||||
return [distribution, semesters];
|
return [distribution, semesters, instructorIncluded];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -88,18 +87,19 @@ export async function queryAggregateDistribution(course: Course): Promise<[Distr
|
|||||||
* @param semester the semester to fetch the distribution for OR null if we want the aggregate distribution
|
* @param semester the semester to fetch the distribution for OR null if we want the aggregate distribution
|
||||||
* @returns a SQL query string
|
* @returns a SQL query string
|
||||||
*/
|
*/
|
||||||
function generateQuery(course: Course, semester: Semester | null): string {
|
function generateQuery(course: Course, semester: Semester | null, includeInstructor: boolean): string {
|
||||||
// const profName = course.instructors[0]?.fullName;
|
const profName = course.instructors[0]?.lastName;
|
||||||
// eslint-disable-next-line no-nested-ternary
|
|
||||||
const yearDelta = semester ? (semester.season === 'Fall' ? 0 : -1) : 0;
|
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
select * from ${semester ? `grade_distributions_${semester.year + yearDelta}_${semester.year + yearDelta + 1}` : `(select * from ${allTables.join(' union all select * from ')})`}
|
select * from grade_distributions
|
||||||
where Department_Code = '${course.department}'
|
where Department_Code = '${course.department}'
|
||||||
and Course_Number = '${course.number}'
|
and Course_Number = '${course.number}'
|
||||||
|
${includeInstructor ? `and Instructor_Last = '${profName}' collate nocase` : ''}
|
||||||
${semester ? `and Semester = '${semester.season} ${semester.year}'` : ''}
|
${semester ? `and Semester = '${semester.season} ${semester.year}'` : ''}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
console.log(includeInstructor, { query });
|
||||||
|
|
||||||
return query;
|
return query;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,14 +109,20 @@ function generateQuery(course: Course, semester: Semester | null): string {
|
|||||||
* @param semester the semester to fetch the distribution for
|
* @param semester the semester to fetch the distribution for
|
||||||
* @returns a Distribution object containing the distribution of grades for the course
|
* @returns a Distribution object containing the distribution of grades for the course
|
||||||
*/
|
*/
|
||||||
export async function querySemesterDistribution(course: Course, semester: Semester): Promise<Distribution> {
|
export async function querySemesterDistribution(course: Course, semester: Semester): Promise<[Distribution, boolean]> {
|
||||||
const db = await initializeDB();
|
const db = await initializeDB();
|
||||||
const query = generateQuery(course, semester);
|
const query = generateQuery(course, semester, true);
|
||||||
|
|
||||||
const res = db.exec(query)?.[0];
|
let res = db.exec(query)?.[0];
|
||||||
|
let instructorIncluded = true;
|
||||||
|
if (!res?.columns?.length) {
|
||||||
|
instructorIncluded = false;
|
||||||
|
const queryWithoutInstructor = generateQuery(course, semester, false);
|
||||||
|
res = db.exec(queryWithoutInstructor)?.[0];
|
||||||
if (!res?.columns?.length) {
|
if (!res?.columns?.length) {
|
||||||
throw new NoDataError(course);
|
throw new NoDataError(course);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const row: Required<CourseSQLRow> = {} as Required<CourseSQLRow>;
|
const row: Required<CourseSQLRow> = {} as Required<CourseSQLRow>;
|
||||||
for (let i = 0; i < res.columns.length; i++) {
|
for (let i = 0; i < res.columns.length; i++) {
|
||||||
@@ -142,7 +148,8 @@ export async function querySemesterDistribution(course: Course, semester: Semest
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return [
|
||||||
|
{
|
||||||
A: row.A,
|
A: row.A,
|
||||||
'A-': row.A_Minus,
|
'A-': row.A_Minus,
|
||||||
'B+': row.B_Plus,
|
'B+': row.B_Plus,
|
||||||
@@ -156,7 +163,9 @@ export async function querySemesterDistribution(course: Course, semester: Semest
|
|||||||
'D-': row.D_Minus,
|
'D-': row.D_Minus,
|
||||||
F: row.F,
|
F: row.F,
|
||||||
Other: row.Other,
|
Other: row.Other,
|
||||||
};
|
},
|
||||||
|
instructorIncluded,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user