diff --git a/package.json b/package.json index 641c347..fd357c1 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ "host_permissions": [ "https://utdallas.collegescheduler.com/terms/*/courses/*", "https://www.ratemyprofessors.com/", - "https://api.utdnebula.com/" + "https://trends.utdnebula.com/" ], "browser_specific_settings": { "gecko": { diff --git a/src/components/HorizontalScores.tsx b/src/components/HorizontalScores.tsx index adc8764..29a33da 100644 --- a/src/components/HorizontalScores.tsx +++ b/src/components/HorizontalScores.tsx @@ -15,28 +15,42 @@ export const HorizontalScores = ({ }: Scores) => { return (
-

+

RMP

-

DIFF

-

+

+ DIFF +

+

WTA

{rmpScore ? rmpScore.toFixed(1) : 'NA'}

{diffScore ? diffScore.toFixed(1) : 'NA'}

{wtaPercent ? Math.round(wtaPercent) : 'NA'}%

diff --git a/src/components/LinkButton.tsx b/src/components/LinkButton.tsx deleted file mode 100644 index 63fbd97..0000000 --- a/src/components/LinkButton.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import React from 'react'; - -// left as an exercise for the reader -export const LinkButton = () => { - return
; -}; diff --git a/src/components/MiniGrades.tsx b/src/components/MiniGrades.tsx index 236ab1b..0cfeab1 100644 --- a/src/components/MiniGrades.tsx +++ b/src/components/MiniGrades.tsx @@ -3,23 +3,13 @@ import Chart from 'react-apexcharts'; import { miniGradeChartOptions } from '~utils/styling'; -import type { GradeDistribution } from './ProfileGrades'; - -export const MiniGrades = ({ - gradeDistributionData, -}: { - gradeDistributionData: GradeDistribution; -}) => { - const config = JSON.parse(JSON.stringify(miniGradeChartOptions)); - config.title.text = gradeDistributionData.name; +export const MiniGrades = ({ series }: { series: ApexAxisChartSeries }) => { return ( - <> - - + ); }; diff --git a/src/components/MiniProfessor.tsx b/src/components/MiniProfessor.tsx index d0edf1a..9b716f5 100644 --- a/src/components/MiniProfessor.tsx +++ b/src/components/MiniProfessor.tsx @@ -34,33 +34,33 @@ export const MiniProfessor = ({ -
-
- -
- -
- -
-
- +
+ + + +
+
diff --git a/src/components/MiniScore.tsx b/src/components/MiniScore.tsx index 0485cd3..ad167d8 100644 --- a/src/components/MiniScore.tsx +++ b/src/components/MiniScore.tsx @@ -4,31 +4,34 @@ import { getScoreColor } from '~utils/styling'; interface MiniScoreProps { name: 'RMP' | 'DIFF' | 'WTA'; + title: string; score: number; maxScore: number; inverted: boolean; + className: string; } export const MiniScore = ({ name, + title, score, maxScore, inverted, + className, }: MiniScoreProps) => { return ( -
+

{name}

- {score !== undefined && ( + {score !== undefined ? (

- {name === 'WTA' ? Math.round(score) : score.toFixed(1)} + {name === 'WTA' ? Math.round(score) + '%' : score.toFixed(1)}

- )} - {score === undefined && ( + ) : (

{ + return ( + + ); +}; diff --git a/src/components/ProfileGrades.tsx b/src/components/ProfileGrades.tsx index 786c19f..c7e2840 100644 --- a/src/components/ProfileGrades.tsx +++ b/src/components/ProfileGrades.tsx @@ -1,6 +1,5 @@ -import React, { useState } from 'react'; +import React from 'react'; import Chart from 'react-apexcharts'; -import { AiFillCaretLeft, AiFillCaretRight } from 'react-icons/ai'; import { gradeChartOptions } from '~utils/styling'; @@ -10,53 +9,25 @@ export interface GradeDistribution { } export const ProfileGrades = ({ - gradeDistributionData, + series, + total, }: { - gradeDistributionData: GradeDistribution[]; + series: GradeDistribution; + total: number; }) => { - const [page, setPage] = useState(0); - - const prevPage = () => { - if (page === 0) { - setPage(gradeDistributionData.length - 1); - } else { - setPage(page - 1); - } - }; - - const nextPage = () => { - if (page === gradeDistributionData.length - 1) { - setPage(0); - } else { - setPage(page + 1); - } - }; - return ( <> -
- -

- {gradeDistributionData[page].name} +
+

+ {'Grades Distribution (' + total + ')'}

-
-
+
diff --git a/src/components/ProfileHeader.tsx b/src/components/ProfileHeader.tsx index 0a97bf4..1aa7e7b 100644 --- a/src/components/ProfileHeader.tsx +++ b/src/components/ProfileHeader.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { FaExternalLinkAlt } from 'react-icons/fa'; import { TiArrowBack } from 'react-icons/ti'; import { NavigateFunction, useNavigate } from 'react-router-dom'; @@ -8,12 +7,10 @@ import type { ProfessorProfileInterface } from '~data/builder'; export const ProfileHeader = ({ name, profilePicUrl, - rmpId, profiles, }: { name: string; profilePicUrl: string; - rmpId: number; profiles: ProfessorProfileInterface[]; }) => { const navigation: NavigateFunction = useNavigate(); @@ -22,13 +19,6 @@ export const ProfileHeader = ({ navigation('/', { state: profiles }); }; - const navigativeToRmp = (): void => { - window.open( - 'https://www.ratemyprofessors.com/professor/' + rmpId, - '_blank', - ); - }; - return (
@@ -45,16 +35,6 @@ export const ProfileHeader = ({

{name.split(' ').at(0) + ' ' + name.split(' ').at(-1)}

-
{ return (

- Ratings Distribution + {'Ratings Distribution (' + total + ')'}

diff --git a/src/data/builder.ts b/src/data/builder.ts index 4548de5..5b13b2b 100644 --- a/src/data/builder.ts +++ b/src/data/builder.ts @@ -1,12 +1,8 @@ import type { ShowCourseTabPayload } from '~background'; -import { requestProfessorsFromRmp } from '~data/fetchFromRmp'; +import { requestProfessorFromRmp } from '~data/fetchFromRmp'; import { SCHOOL_ID } from './config'; -import { - fetchNebulaCourse, - fetchNebulaProfessor, - fetchNebulaSections, -} from './fetch'; +import { fetchNebulaGrades, fetchNebulaProfessor } from './fetch'; export interface ProfessorProfileInterface { name: string; @@ -17,7 +13,7 @@ export interface ProfessorProfileInterface { wtaScore: number; rmpTags: string[]; gradeDistributions: GradeDistribution[]; - ratingsDistribution: number[]; // temp + ratingsDistribution: number[]; } interface GradeDistribution { @@ -25,50 +21,83 @@ interface GradeDistribution { series: ApexAxisChartSeries; } -const compareArrays = (a, b) => { - return JSON.stringify(a) === JSON.stringify(b); -}; +function combineAndNormalizeGrades(gradeData) { + const totalGrades = []; -export async function buildProfessorProfiles(payload: ShowCourseTabPayload) { - const { header, professors } = payload; - const nebulaCourse = await fetchNebulaCourse(header); - const nebulaProfessors = await Promise.all( - professors.map((professor) => { - const nameArray = professor.split(' '); - const firstName = nameArray[0]; - const lastName = nameArray[nameArray.length - 1]; - return fetchNebulaProfessor({ firstName: firstName, lastName: lastName }); - }), - ); - const nebulaSections = await Promise.all( - nebulaProfessors.map((professor) => { - if (professor?._id === undefined || !nebulaCourse) return null; - return fetchNebulaSections({ - courseReference: nebulaCourse._id, - professorReference: professor._id, - }); - }), + //combine academic sections + gradeData = gradeData.map((professor) => + professor === null + ? null + : professor.reduce( + (accumulator, academicSession) => { + return accumulator.map( + (value, index) => + value + academicSession.grade_distribution[index] ?? 0, + ); + }, + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + ), ); - const rmps = await requestProfessorsFromRmp({ - professorNames: professors.map( - (prof) => prof.split(' ')[0] + ' ' + prof.split(' ').at(-1), - ), - schoolId: SCHOOL_ID, + + //divide by total + gradeData = gradeData.map((professor) => { + if (professor === null) { + totalGrades.push(0); + return null; + } + const total = professor.reduce( + (accumulator, grade) => grade + accumulator, + 0, + ); + totalGrades.push(total); + return professor.map((grade) => (grade / total) * 100); + }); + + return [totalGrades, gradeData]; +} + +export async function buildProfessorProfiles(payload: ShowCourseTabPayload) { + let { professors } = payload; + professors = professors.map((prof) => { + const parts = prof.split(' '); + return { + profFirst: parts[0], + profLast: parts[parts.length - 1], + }; }); + + let nebulaProfessors, nebulaGrades, rmps; + + const nebulaProfessorsPromises = Promise.all( + professors.map(fetchNebulaProfessor), + ).then((result) => (nebulaProfessors = result)); + + const nebulaGradesPromises = Promise.all( + professors.map(fetchNebulaGrades), + ).then((result) => (nebulaGrades = result)); + + const rmpsPromises = Promise.all( + professors.map((prof) => + requestProfessorFromRmp({ + professorName: prof.profFirst + ' ' + prof.profLast, + schoolId: SCHOOL_ID, + }), + ), + ).then((result) => (rmps = result)); + + await Promise.all([ + nebulaProfessorsPromises, + nebulaGradesPromises, + rmpsPromises, + ]); + + let totalGrades = []; + [totalGrades, nebulaGrades] = combineAndNormalizeGrades(nebulaGrades); + const professorProfiles: ProfessorProfileInterface[] = []; for (let i = 0; i < professors.length; i++) { - const sectionsWithGrades = []; - for (let j = 0; j < nebulaSections[i]?.length; j++) { - const section = nebulaSections[i][j]; - if ( - section.grade_distribution && - section.grade_distribution.length !== 0 && - !compareArrays(section.grade_distribution, Array(14).fill(0)) - ) - sectionsWithGrades.push(section); - } professorProfiles.push({ - name: professors[i], + name: professors[i].profFirst + ' ' + professors[i].profLast, profilePicUrl: nebulaProfessors[i]?.image_uri, rmpId: rmps[i]?.legacyId, rmpScore: rmps[i]?.avgRating @@ -89,21 +118,17 @@ export async function buildProfessorProfiles(payload: ShowCourseTabPayload) { rmpTags: rmps[i]?.teacherRatingTags .sort((a, b) => a.tagCount - b.tagCount) .map((tag) => tag.tagName), - gradeDistributions: - sectionsWithGrades.length > 0 - ? sectionsWithGrades.map((section) => ({ - name: [ - nebulaCourse.subject_prefix, - nebulaCourse.course_number, - section.section_number, - section.academic_session.name, - ].join(' '), - series: [{ name: 'Students', data: section.grade_distribution }], - })) - : [{ name: 'No Data', series: [{ name: 'Students', data: [] }] }], + gradeDistribution: [ + { + name: professors[i].profFirst + ' ' + professors[i].profLast, + data: nebulaGrades[i] ?? [], + }, + ], + totalGrades: totalGrades[i], ratingsDistribution: rmps[i] ? Object.values(rmps[i].ratingsDistribution).reverse().slice(1) : [], + totalRatings: rmps[i]?.ratingsDistribution?.total ?? 0, }); } return professorProfiles; diff --git a/src/data/config.ts b/src/data/config.ts index 643e151..38ee566 100644 --- a/src/data/config.ts +++ b/src/data/config.ts @@ -11,23 +11,5 @@ export const PROFESSOR_QUERY = { variables: {}, }; -export const NEBULA_FETCH_OPTIONS = { - method: 'GET', - headers: { - 'x-api-key': unRegister('EM~eW}G<}4qx41fp{H=I]OZ5MF6T:1x{ | null { - try { - const res = await fetch( - `https://api.utdnebula.com/course?course_number=${params.courseNumber}&subject_prefix=${params.subjectPrefix}`, - NEBULA_FETCH_OPTIONS, - ); - const json = await res.json(); - if (json.data == null) throw new Error('Null data'); - const data: CourseInterface = json.data[0]; - return data; - } catch (error) { - return null; - } +interface FetchProfessorParameters { + profFirst: string; + profLast: string; } export async function fetchNebulaProfessor( params: FetchProfessorParameters, -): Promise | null { - try { - const res = await fetch( - `https://api.utdnebula.com/professor?first_name=${params.firstName}&last_name=${params.lastName}`, - NEBULA_FETCH_OPTIONS, - ); - const json = await res.json(); - if (json.data == null) throw new Error('Null data'); - const data: ProfessorInterface = json.data[0]; - return data; - } catch (error) { - return null; - } +): Promise { + return fetch( + 'https://trends.utdnebula.com/api/professor?profFirst=' + + params.profFirst + + '&profLast=' + + params.profLast, + { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }, + ) + .then((response) => response.json()) + .then((data) => { + if (data.message !== 'success') { + //throw new Error(data.message); + return null; + } + return data.data; + }) + .catch((error) => { + console.error('Nebula API', error); + }); } -export async function fetchNebulaSections( - params: FetchSectionParameters, -): Promise | null { - try { - const res = await fetch( - `https://api.utdnebula.com/section?course_reference=${params.courseReference}&professors=${params.professorReference}`, - NEBULA_FETCH_OPTIONS, - ); - const json = await res.json(); - if (json.data == null) throw new Error('Null data'); - const data: SectionInterface[] = json.data; - return data; - } catch (error) { - return null; - } +export async function fetchNebulaGrades( + params: FetchProfessorParameters, +): Promise { + return fetch( + 'https://trends.utdnebula.com/api/grades?profFirst=' + + params.profFirst + + '&profLast=' + + params.profLast, + { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }, + ) + .then((response) => response.json()) + .then((data) => { + if (data.message !== 'success') { + throw new Error(data.message); + } + return data.data; + }) + .catch((error) => { + console.error('Nebula API', error); + }); } // Test function. Commented out. Uncomment to test. diff --git a/src/data/fetchFromRmp.ts b/src/data/fetchFromRmp.ts index a6d6bbc..4bc1255 100644 --- a/src/data/fetchFromRmp.ts +++ b/src/data/fetchFromRmp.ts @@ -5,60 +5,45 @@ function reportError(context, err) { } function getProfessorUrl(professorName: string, schoolId: string): string { - return `https://www.ratemyprofessors.com/search/professors/${schoolId}?q=${encodeURIComponent( - professorName, - )}}`; -} -function getProfessorUrls( - professorNames: string[], - schoolId: string, -): string[] { - const professorUrls = []; - for (let i = 0; i < professorNames.length; i++) { - professorUrls.push(getProfessorUrl(professorNames[i], schoolId)); - } - return professorUrls; + const url = new URL( + 'https://www.ratemyprofessors.com/search/professors/' + schoolId + '?', + ); //UTD + url.searchParams.append('q', professorName); + return url.href; } -function getProfessorIds(texts: string[], professorNames: string[]): string[] { - const professorIds = []; - const lowerCaseProfessorNames = professorNames.map((name) => - name.toLowerCase(), - ); - texts.forEach((text) => { - let pendingMatch = null; - const regex = - /"legacyId":(\d+).*?"numRatings":(\d+).*?"firstName":"(.*?)","lastName":"(.*?)"/g; - const allMatches: string[] = text.match(regex); - const highestNumRatings = 0; - - if (allMatches) { - for (const fullMatch of allMatches) { - for (const match of fullMatch.matchAll(regex)) { - console.log( - match[3].split(' ')[0].toLowerCase() + - ' ' + - match[4].toLowerCase() + - ' ', - ); - const numRatings = parseInt(match[2]); - if ( - lowerCaseProfessorNames.includes( - match[3].split(' ')[0].toLowerCase() + - ' ' + - match[4].toLowerCase(), - ) && - numRatings >= highestNumRatings - ) { - pendingMatch = match[1]; - } +function getProfessorId(text: string, professorName: string): string { + const lowerCaseProfessorName = professorName.toLowerCase(); + + let pendingMatch = null; + const regex = + /"legacyId":(\d+).*?"numRatings":(\d+).*?"firstName":"(.*?)","lastName":"(.*?)"/g; + const allMatches: string[] = text.match(regex); + const highestNumRatings = 0; + + if (allMatches) { + for (const fullMatch of allMatches) { + for (const match of fullMatch.matchAll(regex)) { + console.log( + match[3].split(' ')[0].toLowerCase() + + ' ' + + match[4].toLowerCase() + + ' ', + ); + const numRatings = parseInt(match[2]); + if ( + lowerCaseProfessorName.includes( + match[3].split(' ')[0].toLowerCase() + ' ' + match[4].toLowerCase(), + ) && + numRatings >= highestNumRatings + ) { + pendingMatch = match[1]; } } } + } - professorIds.push(pendingMatch); - }); - return professorIds; + return pendingMatch; } function getGraphQlUrlProp(professorId: string) { @@ -72,13 +57,6 @@ function getGraphQlUrlProp(professorId: string) { body: JSON.stringify(PROFESSOR_QUERY), }; } -function getGraphQlUrlProps(professorIds: string[]) { - const graphQlUrlProps = []; - professorIds.forEach((professorId) => { - graphQlUrlProps.push(getGraphQlUrlProp(professorId)); - }); - return graphQlUrlProps; -} function wait(delay) { return new Promise((resolve) => setTimeout(resolve, delay)); @@ -97,89 +75,79 @@ function fetchRetry(url: string, delay: number, tries: number, fetchOptions) { return fetch(url, fetchOptions).catch(onError); } -// If using orderedFetchOpts, make sure that it is an array and that the index of the fetch options corresponds to the index of the response in the responses array. -async function validateResponses(responses, orderedFetchOpts) { - for (const [key, value] of Object.entries(responses)) { - const notOk = value?.status !== 200; - if (notOk && value && value.url) { - const details = { - status: value.status, - statusText: value.statusText, - redirected: value.redirected, - url: value.url, - }; - reportError( - 'validateResponses', - 'Status not OK for fetch request. Details are: ' + - JSON.stringify(details), - ); - const fetchOptions = orderedFetchOpts[key] || {}; // If we don't have fetch options, we just use an empty object. - responses[key] = await fetchRetry(value?.url, 200, 3, fetchOptions); - } +async function validateResponse(response, fetchOptions) { + const notOk = response?.status !== 200; + if (notOk && response && response.url) { + const details = { + status: response.status, + statusText: response.statusText, + redirected: response.redirected, + url: response.url, + }; + reportError( + 'validateResponse', + 'Status not OK for fetch request. Details are: ' + + JSON.stringify(details), + ); + // If we don't have fetch options, we just use an empty object. + response = await fetchRetry(response?.url, 200, 3, fetchOptions || {}); } - return responses; + return response; } -export async function fetchWithGraphQl(graphQlUrlProps) { +function fetchWithGraphQl(graphQlUrlProp, resolve) { try { - const responses = await validateResponses( - await Promise.all(graphQlUrlProps.map((u) => fetch(RMP_GRAPHQL_URL, u))), - graphQlUrlProps, - ); - // We now have all the responses. So, we consider all the responses, and collect the ratings. - const ratings: RMPRatingInterface[] = await Promise.all( - responses.map((res) => res.json()), + fetch(RMP_GRAPHQL_URL, graphQlUrlProp).then((response) => + validateResponse(response, graphQlUrlProp) + .then((response) => response.json()) + .then((rating) => { + if ( + rating != null && + Object.hasOwn(rating, 'data') && + Object.hasOwn(rating['data'], 'node') + ) { + rating = rating['data']['node']; + } + resolve(rating); + }), ); - for (let i = 0; i < ratings.length; i++) { - if ( - ratings[i] != null && - Object.prototype.hasOwnProperty.call(ratings[i], 'data') && - Object.prototype.hasOwnProperty.call(ratings[i]['data'], 'node') - ) { - ratings[i] = ratings[i]['data']['node']; - } - } - return ratings; } catch (err) { reportError('fetchWithGraphQl', err); - return []; + resolve(null); /// } } export interface RmpRequest { - professorNames: string[]; + professorName: string; schoolId: string; } - -export async function requestProfessorsFromRmp( +export function requestProfessorFromRmp( request: RmpRequest, -): Promise { - // make a list of urls for promises - const professorUrls = getProfessorUrls( - request.professorNames, - request.schoolId, - ); - - // fetch professor ids from each url - try { - const responses = await validateResponses( - await Promise.all(professorUrls.map((u) => fetch(u))), - [], +): Promise { + return new Promise((resolve, reject) => { + // make a list of urls for promises + const professorUrl = getProfessorUrl( + request.professorName, + request.schoolId, ); - const texts = await Promise.all(responses.map((res) => res.text())); - const professorIds = getProfessorIds(texts, request.professorNames); - - // create fetch objects for each professor id - const graphQlUrlProps = getGraphQlUrlProps(professorIds); - - // fetch professor info by id with graphQL - const professors = await fetchWithGraphQl(graphQlUrlProps); - return professors; - } catch (error) { - reportError('requestProfessorsFromRmp', error); - return []; - } + // fetch professor ids from each url + fetch(professorUrl) + .then((response) => response.text()) + .then((text) => { + const professorId = getProfessorId(text, request.professorName); + + // create fetch objects for each professor id + const graphQlUrlProp = getGraphQlUrlProp(professorId); + + // fetch professor info by id with graphQL + fetchWithGraphQl(graphQlUrlProp, resolve); + }) + .catch((error) => { + reportError('requestProfessorFromRmp', error); + reject(error); + }); + }); } interface RMPInterface { diff --git a/src/pages/ProfessorProfile.tsx b/src/pages/ProfessorProfile.tsx index c5c5ad2..76f61f7 100644 --- a/src/pages/ProfessorProfile.tsx +++ b/src/pages/ProfessorProfile.tsx @@ -3,7 +3,7 @@ import { useLocation } from 'react-router-dom'; import { Card } from '~components/Card'; import { HorizontalScores } from '~components/HorizontalScores'; -import { LinkButton } from '~components/LinkButton'; +import { ProfileFooter } from '~components/ProfileFooter'; import { ProfileGrades } from '~components/ProfileGrades'; import { ProfileHeader } from '~components/ProfileHeader'; import { RmpRatings } from '~components/RmpRatings'; @@ -30,7 +30,6 @@ export const ProfessorProfile = () => { @@ -55,13 +54,15 @@ export const ProfessorProfile = () => { Array(5).fill(0), ) && ( )} - +
); diff --git a/src/utils/styling.ts b/src/utils/styling.ts index 63685ae..4c0559a 100644 --- a/src/utils/styling.ts +++ b/src/utils/styling.ts @@ -19,7 +19,7 @@ export const gradeChartOptions: ApexOptions = { }, }, noData: { - text: 'No grade data found', + text: 'Grade data unavailable for professor', align: 'center', verticalAlign: 'middle', }, @@ -52,10 +52,6 @@ export const gradeChartOptions: ApexOptions = { id: 'grade-distribution', }, grid: { - padding: { - left: 20, - right: 20, - }, yaxis: { lines: { show: false, @@ -81,7 +77,9 @@ export const gradeChartOptions: ApexOptions = { ], }, yaxis: { - show: false, + labels: { + formatter: (value) => Number(value).toFixed(0) + '%', + }, }, }; @@ -97,7 +95,7 @@ export const ratingsChartOptions: ApexOptions = { id: 'ratings-distribution', }, noData: { - text: 'No data found', + text: 'RMP data unavailable for professor', align: 'center', verticalAlign: 'middle', }, @@ -123,22 +121,8 @@ export const miniGradeChartOptions: ApexOptions = { distributed: true, }, }, - title: { - text: 'Undefined', - align: 'center', - margin: 0, - offsetX: 0, - offsetY: 0, - floating: true, - style: { - fontSize: '12px', - fontWeight: 'semibold', - fontFamily: 'Inter', - color: '#9B9B9B', - }, - }, noData: { - text: 'No grade data found', + text: 'Grade data unavailable for professor', align: 'center', verticalAlign: 'middle', }, @@ -172,9 +156,7 @@ export const miniGradeChartOptions: ApexOptions = { }, grid: { padding: { - left: 15, - right: 5, - bottom: -5, + top: -20, }, yaxis: { lines: { @@ -202,5 +184,8 @@ export const miniGradeChartOptions: ApexOptions = { }, yaxis: { show: false, + labels: { + formatter: (value) => Number(value).toFixed(0) + '%', + }, }, };