Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
233 changes: 159 additions & 74 deletions client/src/components/SearchResults.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { RMPInstructor } from "@utd-grades/db";
import { Col, Row } from "antd";
import debounce from "lodash.debounce";
import type { NextRouter } from "next/router";
import React, { useCallback, useEffect, useRef, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "react-query";
import { animateScroll as scroll } from "react-scroll";
import styled from "styled-components";
Expand Down Expand Up @@ -50,7 +51,7 @@ interface ResultsProps {
router: NextRouter;
}

export default function Results({ search, sectionId, router }: ResultsProps) {
const Results = React.memo(function Results({ search, sectionId, router }: ResultsProps) {
const scrollRef = useRef<HTMLDivElement>(null);

const { data: db } = useDb();
Expand All @@ -64,7 +65,13 @@ export default function Results({ search, sectionId, router }: ResultsProps) {
["sections", search],
// db can't be undefined, because it's only enabled once db is defined
() => db!.getSectionsBySearch(search),
{ enabled: !!db }
{
enabled: !!db && !!search,
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Don't refetch when window gets focus
refetchOnReconnect: false, // Don't refetch on network reconnect
}
);

// get the section data
Expand All @@ -74,119 +81,192 @@ export default function Results({ search, sectionId, router }: ResultsProps) {
error: sectionError,
// db can't be undefined, because it's only enabled once db is defined
} = useQuery(["section", sectionId], () => db!.getSectionById(sectionId), {
enabled: !!db,
enabled: !!db && !isNaN(sectionId) && sectionId > 0,
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Don't refetch when window gets focus
refetchOnReconnect: false, // Don't refetch on network reconnect
});

const { data: relatedSections } = useQuery(
[
"relatedSections",
section && {
courseNumber: section.catalogNumber,
coursePrefix: section.subject,
},
section?.catalogNumber,
section?.subject,
],
() =>
// TODO (field search)
// db can't be undefined, because it's only enabled once section is defined which implies db is defined
db!.getSectionsBySearch(
`${section!.catalogNumber} ${section!.subject}` // can't be null because we guard on `section`
),
{ enabled: !!section }
{
enabled: !!section && !!db,
staleTime: 5 * 60 * 1000,
cacheTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
}
);

// some professors have the same name so we need to get the whole list
const normalName: string[] = normalizeName(
`${section?.instructor1?.first} ${section?.instructor1?.last}`
const normalName: string[] = useMemo(() =>
section ? normalizeName(`${section.instructor1?.first} ${section.instructor1?.last}`) : [],
[section]
);

const { data: instructors } = useQuery<RMPInstructor[]>(
["instructors", sectionId],
["instructors", section?.instructor1?.first, section?.instructor1?.last],
async () => {
const results = await Promise.all(normalName.map((name) => db!.getInstructorsByName(name)));
if (!normalName.length || !db) return [];
const results = await Promise.all(normalName.map((name) => db.getInstructorsByName(name)));
return results.flat();
},
{ enabled: !!section }
{
enabled: !!section && !!db && normalName.length > 0,
staleTime: 5 * 60 * 1000,
cacheTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
}
);

// Minimal debug logging
// from that list, we need to find the one that holds the session -> update the instructor and course rating
const [instructor, setInstructor] = useState<RMPInstructor>();
const [courseRating, setCourseRating] = useState<number | null>(null);

useEffect(() => {
if (instructors && section) {
// when there is only professor that matches the needed name -> set the instructor to that prof
// this helps prevent that some of the courses may not be listed in the RMP data but we still want to the prof data

// however, if there're 2 profs with the same name and the course we're looking for is not listed in either instructor's RMP courses
// then we don't know who to return
// this will not be a problem when the new RMP data is updated
if (instructors.length === 1) {
setInstructor(instructors[0]);
const rating = db!.getCourseRating(
instructors[0]!.instructor_id,
if (!instructors || !section || !db) {
setInstructor(undefined);
setCourseRating(null);
return;
}

// when there is only professor that matches the needed name -> set the instructor to that prof
// this helps prevent that some of the courses may not be listed in the RMP data but we still want to the prof data

// however, if there're 2 profs with the same name and the course we're looking for is not listed in either instructor's RMP courses
// then we don't know who to return
// this will not be a problem when the new RMP data is updated
if (instructors.length === 1) {
const inst = instructors[0];
setInstructor(inst);
const rating = db.getCourseRating(
inst!.instructor_id,
`${section.subject}${section.catalogNumber}`
);
setCourseRating(rating);
} else {
let foundInstructor: RMPInstructor | undefined;
let foundRating: number | null = null;

for (const ins of instructors) {
const rating = db.getCourseRating(
ins.instructor_id,
`${section.subject}${section.catalogNumber}`
);
setCourseRating(rating);
} else {
for (const ins of instructors) {
const rating = db!.getCourseRating(
ins.instructor_id,
`${section.subject}${section.catalogNumber}`
);
if (rating) {
setInstructor(ins);
setCourseRating(rating);
break;
}
if (rating) {
foundInstructor = ins;
foundRating = rating;
break;
}
}
} else {
setInstructor(undefined);
setCourseRating(null);

setInstructor(foundInstructor);
setCourseRating(foundRating);
}
}, [instructors, section, db]);

const handleClick = useCallback((id: number) => {
(async function () {
await router.push({
pathname: "/results",
query: { search, sectionId: id },
});
})();

const scrollDistance = window.scrollY + scrollRef.current!.getBoundingClientRect().top;
const stableRouter = useRef(router);
const stableSearch = useRef(search);
const navigationInProgress = useRef(false);

scroll.scrollTo(scrollDistance);
// Update refs when props change
useEffect(() => {
stableRouter.current = router;
stableSearch.current = search;
}, [router, search]);

useEffect(() => {
// Automatically select section if there is only one choice
if (sections && sections.length == 1) {
handleClick(sections[0]!.id);
// Debounced navigation to prevent rapid clicks
const debouncedNavigate = useMemo(
() => debounce(async (id: number) => {
if (navigationInProgress.current) {
return;
}

navigationInProgress.current = true;

try {
// Use shallow routing to prevent page scroll reset
await stableRouter.current.push({
pathname: "/results",
query: { search: stableSearch.current, sectionId: id },
}, undefined, { shallow: false, scroll: false });

// Always scroll to show the graph/content area when a section is clicked
if (scrollRef.current) {
const contentArea = scrollRef.current;
const contentRect = contentArea.getBoundingClientRect();
const targetScrollY = window.scrollY + contentRect.top - 80; // 80px padding from top

scroll.scrollTo(Math.max(0, targetScrollY), {
duration: 400,
smooth: true
});
}
} catch (error: unknown) {
if (error instanceof Error && !error.message.includes('Abort')) {
console.error('Navigation error:', error);
}
} finally {
navigationInProgress.current = false;
}
}, 300),
[]
);

const handleClick = useCallback((id: number) => {
if (!scrollRef.current) return;

// Don't navigate if we're already on this section
if (id === sectionId) {
return;
}
}, [sections, handleClick]);

function handleSubmit({ search }: SearchQuery) {
(async function () {
await router.push({
pathname: "/results",
query: { search },
});
})();
}
void debouncedNavigate(id);
}, [sectionId, debouncedNavigate]);

function handleRelatedSectionClick(search: string, id: number) {
(async function () {
await router.push({
pathname: "/results",
query: { search, sectionId: id },
});
})();

const scrollDistance = window.scrollY + scrollRef.current!.getBoundingClientRect().top;

scroll.scrollTo(scrollDistance);
}
const handleSubmit = useCallback(({ search }: SearchQuery) => {
void stableRouter.current.push({
pathname: "/results",
query: { search },
}).catch(error => {
console.error('Navigation error:', error);
});
}, []);

const handleRelatedSectionClick = useCallback((search: string, id: number) => {
void stableRouter.current.push({
pathname: "/results",
query: { search, sectionId: id },
}, undefined, { shallow: false, scroll: false }).then(() => {
if (!scrollRef.current) return;

const contentArea = scrollRef.current;
const contentRect = contentArea.getBoundingClientRect();
const targetScrollY = window.scrollY + contentRect.top - 80;

scroll.scrollTo(Math.max(0, targetScrollY), {
duration: 400,
smooth: true
});
}).catch(error => {
console.error('Navigation error:', error);
});
}, []);

return (
<Container>
Expand Down Expand Up @@ -227,4 +307,9 @@ export default function Results({ search, sectionId, router }: ResultsProps) {
</Row>
</Container>
);
}
}, (prevProps, nextProps) => {
return prevProps.search === nextProps.search &&
prevProps.sectionId === nextProps.sectionId;
});

export default Results;
30 changes: 10 additions & 20 deletions client/src/components/SectionContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
Tooltip as ChartTooltip,
} from "chart.js";
import Image from "next/image";
import React, { useRef, useState } from "react";
import React, { useCallback, useRef, useState } from "react";
import { Bar } from "react-chartjs-2";
import styled from "styled-components";
import type { UserFriendlyGrades } from "../types";
Expand Down Expand Up @@ -299,29 +299,17 @@ const getRMPColor = (rating: number): string => {
return "#e74c3c"; // Red
};

export default function SectiSonContent({
const SectionContent = React.memo(function SectionContent({
section,
instructor,
courseRating,
}: SectionContentProps) {
// const renderRelatedSections = () => {
// if (relatedSections) {
// return relatedSections
// .filter((s) => s.id != section.id)
// .map((s) => (
// <SectionCard
// key={s.id} // FIXME
// section={s}
// handleRelatedSectionClick={handleRelatedSectionClick}
// />
// ));
// }

// return <Spin />;
// };
const [hovered, setHovered] = useState<"rmpLink" | null>(null);
const rmpLinkRef = useRef<HTMLAnchorElement>(null);

const handleMouseEnter = useCallback(() => setHovered("rmpLink"), []);
const handleMouseLeave = useCallback(() => setHovered(null), []);

const grades = extractGrades(section);
const keys = Object.keys(grades) as (keyof UserFriendlyGrades)[]; // we can be confident only these keys exist
const values = Object.values(grades);
Expand Down Expand Up @@ -415,8 +403,8 @@ export default function SectiSonContent({
href={instructor?.url || "#"}
target={instructor?.url ? "_blank" : "_self"}
style={{ position: "relative" }}
onMouseEnter={() => setHovered("rmpLink")}
onMouseLeave={() => setHovered(null)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
ref={rmpLinkRef}
>
Professor Details
Expand Down Expand Up @@ -514,4 +502,6 @@ export default function SectiSonContent({
</ProfessorDetailsContainer>
</Container>
);
}
});

export default SectionContent;