diff --git a/.env.template b/.env.template index 2de8b1afb..669c55094 100644 --- a/.env.template +++ b/.env.template @@ -7,6 +7,7 @@ NODE_ENV=development MONGODB_URI=mongodb://mongodb:27017/bt?replicaSet=rs0 REDIS_URI=redis://redis:6379 BACKEND_URL=http://backend:8080 +SEMANTIC_SEARCH_URL=http://semantic-search:8000 TZ=America/Los_Angeles # for tslog SIS_CLASS_APP_ID=_ diff --git a/apps/backend/package.json b/apps/backend/package.json index 9a3c92257..cd4c4b484 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -42,6 +42,7 @@ "@repo/gql-typedefs": "*", "@repo/shared": "*", "@repo/sis-api": "*", + "undici": "^6.20.1", "compression": "^1.8.1", "connect-redis": "^9.0.0", "cors": "^2.8.5", diff --git a/apps/backend/scripts/prepare-typedefs.js b/apps/backend/scripts/prepare-typedefs.js index 6b7c47668..4e7f4ff27 100644 --- a/apps/backend/scripts/prepare-typedefs.js +++ b/apps/backend/scripts/prepare-typedefs.js @@ -11,7 +11,7 @@ const typedefFiles = fs.readdirSync(sourceDir) .sort(); // Get all module directories from backend/src/modules (excluding non-module directories) -const excludedDirs = ['cache', 'generated-types']; +const excludedDirs = ['cache', 'generated-types', 'semantic-search']; const moduleDirs = fs.readdirSync(modulesDir, { withFileTypes: true }) .filter(dirent => dirent.isDirectory() && !excludedDirs.includes(dirent.name)) .map(dirent => dirent.name) diff --git a/apps/backend/src/bootstrap/loaders/express.ts b/apps/backend/src/bootstrap/loaders/express.ts index bad42a2b1..4a5241558 100644 --- a/apps/backend/src/bootstrap/loaders/express.ts +++ b/apps/backend/src/bootstrap/loaders/express.ts @@ -7,6 +7,7 @@ import helmet from "helmet"; import { RedisClientType } from "redis"; import { config } from "../../config"; +import semanticSearchRoutes from "../../modules/semantic-search/routes"; import passportLoader from "./passport"; export default async ( @@ -58,6 +59,8 @@ export default async ( // load authentication passportLoader(app, redis); + app.use("/semantic-search", semanticSearchRoutes); + app.use( config.graphqlPath, expressMiddleware(server, { diff --git a/apps/backend/src/config.ts b/apps/backend/src/config.ts index 8d5f8301e..efbb2c271 100644 --- a/apps/backend/src/config.ts +++ b/apps/backend/src/config.ts @@ -20,6 +20,9 @@ export interface Config { backendPath: string; graphqlPath: string; isDev: boolean; + semanticSearch: { + url: string; + }; mongoDB: { uri: string; }; @@ -45,6 +48,9 @@ export const config: Config = { backendPath: env("BACKEND_PATH"), graphqlPath: env("GRAPHQL_PATH"), isDev: env("NODE_ENV") === "development", + semanticSearch: { + url: env("SEMANTIC_SEARCH_URL"), + }, mongoDB: { uri: env("MONGODB_URI"), }, diff --git a/apps/backend/src/modules/catalog/controller.ts b/apps/backend/src/modules/catalog/controller.ts index e25db0b05..f189f948c 100644 --- a/apps/backend/src/modules/catalog/controller.ts +++ b/apps/backend/src/modules/catalog/controller.ts @@ -11,29 +11,35 @@ import { NewEnrollmentHistoryModel, SectionModel, TermModel, - getAverageGrade, - getDistribution, - getPnpPercentage, - getPnpPercentageFromCounts, } from "@repo/common"; -import { getFields, hasFieldPath } from "../../utils/graphql"; +import { getFields } from "../../utils/graphql"; import { formatClass, formatSection } from "../class/formatter"; import { ClassModule } from "../class/generated-types/module-types"; import { formatCourse } from "../course/formatter"; import { formatEnrollment } from "../enrollment/formatter"; import { EnrollmentModule } from "../enrollment/generated-types/module-types"; +import { + getAverageGrade, + getDistribution, + getPnpPercentage, +} from "../grade-distribution/controller"; import { GradeDistributionModule } from "../grade-distribution/generated-types/module-types"; -const EMPTY_GRADE_DISTRIBUTIONS: readonly IGradeDistributionItem[] = - [] as const; - // TODO: Pagination, filtering export const getCatalog = async ( year: number, semester: string, info: GraphQLResolveInfo ) => { + const term = await TermModel.findOne({ + name: `${year} ${semester}`, + }) + .select({ _id: 1 }) + .lean(); + + if (!term) throw new Error("Invalid term"); + /** * TODO: * Basic pagination can be introduced by using skip and limit @@ -46,39 +52,29 @@ export const getCatalog = async ( * in-memory filtering for fields from courses and sections */ - // Fetch term and classes in parallel - const [term, classes] = await Promise.all([ - TermModel.findOne({ - name: `${year} ${semester}`, - }) - .select({ _id: 1 }) - .lean(), - ClassModel.find({ - year, - semester, - anyPrintInScheduleOfClasses: true, - }).lean(), - ]); - - if (!term) throw new Error("Invalid term"); + // Fetch available classes for the term + const classes = await ClassModel.find({ + year, + semester, + anyPrintInScheduleOfClasses: true, + }).lean(); // Filtering by identifiers reduces the amount of data returned for courses and sections const courseIds = classes.map((_class) => _class.courseId); - const uniqueCourseIds = [...new Set(courseIds)]; - - // Fetch courses and sections in parallel - const [courses, sections] = await Promise.all([ - CourseModel.find({ - courseId: { $in: uniqueCourseIds }, - printInCatalog: true, - }).lean(), - SectionModel.find({ - year, - semester, - courseId: { $in: uniqueCourseIds }, - printInScheduleOfClasses: true, - }).lean(), - ]); + + // Fetch available courses for the term + const courses = await CourseModel.find({ + courseId: { $in: courseIds }, + printInCatalog: true, + }).lean(); + + // Fetch available sections for the term + const sections = await SectionModel.find({ + year, + semester, + courseId: { $in: courseIds }, + printInScheduleOfClasses: true, + }).lean(); let parsedGradeDistributions = {} as Record< string, @@ -86,56 +82,30 @@ export const getCatalog = async ( >; const children = getFields(info.fieldNodes); - const selectionIncludes = (path: string[]) => - info.fieldNodes.some((node) => - node.selectionSet - ? hasFieldPath(node.selectionSet.selections, info.fragments, path) - : false - ); + const includesGradeDistribution = children.includes("gradeDistribution"); - const includesClassGradeDistribution = selectionIncludes([ - "gradeDistribution", - ]); - const includesCourseGradeDistribution = selectionIncludes([ - "course", - "gradeDistribution", - ]); - const includesCourseGradeDistributionDistribution = selectionIncludes([ - "course", - "gradeDistribution", - "distribution", - ]); - - const shouldLoadGradeDistributions = - includesClassGradeDistribution || - includesCourseGradeDistributionDistribution; - - if (shouldLoadGradeDistributions) { + if (includesGradeDistribution) { const sectionIds = sections.map((section) => section.sectionId); - // Fetch class-level and course-level grade distributions in parallel when needed - const [classGradeDistributions, courseGradeDistributions] = - await Promise.all([ - includesClassGradeDistribution - ? GradeDistributionModel.find({ - sectionId: { $in: sectionIds }, - }).lean() - : Promise.resolve(EMPTY_GRADE_DISTRIBUTIONS), - includesCourseGradeDistributionDistribution - ? GradeDistributionModel.find({ - $or: [ - ...courses.map((course) => ({ - subject: course.subject, - courseNumber: course.number, - })), - ...classes.map((_class) => ({ - subject: _class.subject, - courseNumber: _class.courseNumber, - })), - ], - }).lean() - : Promise.resolve(EMPTY_GRADE_DISTRIBUTIONS), - ]); + // Get class-level grade distributions (current semester only) + const classGradeDistributions = await GradeDistributionModel.find({ + sectionId: { $in: sectionIds }, + }).lean(); + + // Get course-level grade distributions (all semesters/history) + // Include both the cross-listed parent courses AND the classes themselves + const courseGradeDistributions = await GradeDistributionModel.find({ + $or: [ + ...courses.map((course) => ({ + subject: course.subject, + courseNumber: course.number, + })), + ...classes.map((_class) => ({ + subject: _class.subject, + courseNumber: _class.courseNumber, + })), + ], + }).lean(); // Separate processing for class-level and course-level distributions const reducedGradeDistributions = {} as Record< @@ -143,50 +113,46 @@ export const getCatalog = async ( GradeDistributionModule.GradeDistribution >; - if (includesClassGradeDistribution) { - // Process class-level distributions (by sectionId) - const classBySection = classGradeDistributions.reduce( - (acc, gradeDistribution) => { - const sectionId = gradeDistribution.sectionId; - acc[sectionId] = acc[sectionId] - ? [...acc[sectionId], gradeDistribution] - : [gradeDistribution]; - return acc; - }, - {} as Record - ); - - for (const [sectionId, distributions] of Object.entries(classBySection)) { - const distribution = getDistribution(distributions); - reducedGradeDistributions[sectionId] = { - average: getAverageGrade(distribution), - distribution, - pnpPercentage: getPnpPercentage(distribution), - } as GradeDistributionModule.GradeDistribution; - } + // Process class-level distributions (by sectionId) + const classBySection = classGradeDistributions.reduce( + (acc, gradeDistribution) => { + const sectionId = gradeDistribution.sectionId; + acc[sectionId] = acc[sectionId] + ? [...acc[sectionId], gradeDistribution] + : [gradeDistribution]; + return acc; + }, + {} as Record + ); + + for (const [sectionId, distributions] of Object.entries(classBySection)) { + const distribution = getDistribution(distributions); + reducedGradeDistributions[sectionId] = { + average: getAverageGrade(distribution), + distribution, + pnpPercentage: getPnpPercentage(distribution), + } as GradeDistributionModule.GradeDistribution; } - if (includesCourseGradeDistributionDistribution) { - // Process course-level distributions (by subject-number, all history) - const courseByCourse = courseGradeDistributions.reduce( - (acc, gradeDistribution) => { - const key = `${gradeDistribution.subject}-${gradeDistribution.courseNumber}`; - acc[key] = acc[key] - ? [...acc[key], gradeDistribution] - : [gradeDistribution]; - return acc; - }, - {} as Record - ); - - for (const [key, distributions] of Object.entries(courseByCourse)) { - const distribution = getDistribution(distributions); - reducedGradeDistributions[key] = { - average: getAverageGrade(distribution), - distribution, - pnpPercentage: getPnpPercentage(distribution), - } as GradeDistributionModule.GradeDistribution; - } + // Process course-level distributions (by subject-number, all history) + const courseByCourse = courseGradeDistributions.reduce( + (acc, gradeDistribution) => { + const key = `${gradeDistribution.subject}-${gradeDistribution.courseNumber}`; + acc[key] = acc[key] + ? [...acc[key], gradeDistribution] + : [gradeDistribution]; + return acc; + }, + {} as Record + ); + + for (const [key, distributions] of Object.entries(courseByCourse)) { + const distribution = getDistribution(distributions); + reducedGradeDistributions[key] = { + average: getAverageGrade(distribution), + distribution, + pnpPercentage: getPnpPercentage(distribution), + } as GradeDistributionModule.GradeDistribution; } parsedGradeDistributions = reducedGradeDistributions; @@ -272,25 +238,16 @@ export const getCatalog = async ( // Add grade distribution to course // Use the class's subject and courseNumber to get the correct grades for cross-listed courses - if (includesCourseGradeDistribution) { - const courseFallback = { - average: course.allTimeAverageGrade ?? null, + if (includesGradeDistribution) { + const key = `${_class.subject}-${_class.courseNumber}`; + const gradeDistribution = parsedGradeDistributions[key]; + + // Fall back to an empty grade distribution to prevent resolving the field again + formattedCourse.gradeDistribution = gradeDistribution ?? { + average: null, distribution: [], - pnpPercentage: getPnpPercentageFromCounts( - course.allTimePassCount, - course.allTimeNoPassCount - ), + pnpPercentage: null, }; - - if (includesCourseGradeDistributionDistribution) { - const key = `${_class.subject}-${_class.courseNumber}`; - const gradeDistribution = - parsedGradeDistributions[key] ?? courseFallback; - - formattedCourse.gradeDistribution = gradeDistribution; - } else { - formattedCourse.gradeDistribution = courseFallback; - } } const formattedClass = { @@ -301,7 +258,7 @@ export const getCatalog = async ( } as unknown as ClassModule.Class; // Add grade distribution to class - if (includesClassGradeDistribution) { + if (includesGradeDistribution) { const sectionId = formattedPrimarySection.sectionId; // Fall back to an empty grade distribution to prevent resolving the field again diff --git a/apps/backend/src/modules/class/formatter.ts b/apps/backend/src/modules/class/formatter.ts index 18a13ade9..09a8e0d1c 100644 --- a/apps/backend/src/modules/class/formatter.ts +++ b/apps/backend/src/modules/class/formatter.ts @@ -5,7 +5,6 @@ import { ISectionItem, } from "@repo/common"; -import { normalizeSubject } from "../../utils/subject"; import { EnrollmentModule } from "../enrollment/generated-types/module-types"; import { ClassModule } from "./generated-types/module-types"; @@ -35,7 +34,6 @@ export const formatDate = (date?: string | number | Date | null) => { export const formatClass = (_class: IClassItem) => { const output = { ..._class, - subject: normalizeSubject(_class.subject), unitsMax: _class.allowedUnits?.maximum || 0, unitsMin: _class.allowedUnits?.minimum || 0, @@ -67,65 +65,12 @@ export type IntermediateSection = Omit< > & SectionRelationships; -/** - * Raw instructor data from database, including the role field. - * The role field is not exposed in GraphQL but used for filtering. - */ -interface RawInstructor { - printInScheduleOfClasses?: boolean; - familyName?: string | null; - givenName?: string | null; - role?: string | null; -} - -/** - * Filters instructors to only show Primary Instructors (PI = professors) - * and sorts them alphabetically by last name for consistent ordering. - * This ensures TAs don't appear in instructor lists across the application. - * - * This is the single source of truth for instructor filtering logic. - */ -export const filterAndSortInstructors = ( - instructors: RawInstructor[] | undefined -): ClassModule.Instructor[] => { - if (!instructors) return []; - - const normalize = ( - list: RawInstructor[], - requireRole: boolean - ): ClassModule.Instructor[] => - list - .filter( - ( - instructor - ): instructor is RawInstructor & { - familyName: string; - givenName: string; - } => - (!requireRole || instructor.role === "PI") && - typeof instructor.familyName === "string" && - typeof instructor.givenName === "string" - ) - .map((instructor) => ({ - familyName: instructor.familyName, - givenName: instructor.givenName, - })) - .sort((a, b) => a.familyName.localeCompare(b.familyName)); - - // Prefer PIs; if none present (data gaps), fall back to any instructors with names. - const primaryInstructors = normalize(instructors, true); - if (primaryInstructors.length > 0) return primaryInstructors; - - return normalize(instructors, false); -}; - export const formatSection = ( section: ISectionItem, enrollment: EnrollmentModule.Enrollment | null | undefined = null ) => { const output = { ...section, - subject: normalizeSubject(section.subject), online: section.instructionMode === "O", course: section.courseId, @@ -135,12 +80,6 @@ export const formatSection = ( term: null, class: null, enrollment: enrollment ?? null, - - // Filter meetings to only show professors (PI), not TAs - meetings: section.meetings?.map((meeting) => ({ - ...meeting, - instructors: filterAndSortInstructors(meeting.instructors), - })), } as IntermediateSection; return output; diff --git a/apps/backend/src/modules/class/resolver.ts b/apps/backend/src/modules/class/resolver.ts index d8e6db1a7..b3c8211ce 100644 --- a/apps/backend/src/modules/class/resolver.ts +++ b/apps/backend/src/modules/class/resolver.ts @@ -14,27 +14,9 @@ import { getSecondarySections, getSection, } from "./controller"; -import { - IntermediateClass, - IntermediateSection, - filterAndSortInstructors, -} from "./formatter"; +import { IntermediateClass, IntermediateSection } from "./formatter"; import { ClassModule } from "./generated-types/module-types"; -/** - * Type for sections that may have unfiltered instructor data from the database. - * Used when sections are embedded in responses and need filtering. - */ -type SectionWithRawInstructors = IntermediateSection & { - meetings?: Array<{ - instructors?: Array<{ - familyName?: string; - givenName?: string; - role?: string; - }>; - }>; -}; - const resolvers: ClassModule.Resolvers = { ClassNumber: new GraphQLScalarType({ name: "ClassNumber", @@ -139,63 +121,33 @@ const resolvers: ClassModule.Resolvers = { }, primarySection: async (parent: IntermediateClass | ClassModule.Class) => { - const primarySection = (parent.primarySection || - (await getPrimarySection( - parent.year, - parent.semester, - parent.sessionId, - parent.subject, - parent.courseNumber, - parent.number - ))) as IntermediateSection | ClassModule.Section | null; - - if (!primarySection) { - return null as unknown as ClassModule.Section; - } + if (parent.primarySection) return parent.primarySection; - // Filter instructors in embedded sections (when queried through course.classes) - // This ensures filtering works even when primarySection comes from raw DB data - const sectionWithRawData = primarySection as SectionWithRawInstructors; - if (sectionWithRawData.meetings) { - const filteredSection: IntermediateSection = { - ...sectionWithRawData, - meetings: sectionWithRawData.meetings.map((meeting) => ({ - ...meeting, - instructors: filterAndSortInstructors(meeting.instructors), - })), - }; - return filteredSection as unknown as ClassModule.Section; - } + const primarySection = await getPrimarySection( + parent.year, + parent.semester, + parent.sessionId, + parent.subject, + parent.courseNumber, + parent.number + ); return primarySection as unknown as ClassModule.Section; }, sections: async (parent: IntermediateClass | ClassModule.Class) => { - const sections = (parent.sections || - (await getSecondarySections( - parent.year, - parent.semester, - parent.sessionId, - parent.subject, - parent.courseNumber, - parent.number - ))) as (IntermediateSection | ClassModule.Section)[]; - - // Filter instructors in all sections - return sections.map((section) => { - const sectionWithRawData = section as SectionWithRawInstructors; - if (sectionWithRawData.meetings) { - const filteredSection: IntermediateSection = { - ...sectionWithRawData, - meetings: sectionWithRawData.meetings.map((meeting) => ({ - ...meeting, - instructors: filterAndSortInstructors(meeting.instructors), - })), - }; - return filteredSection as unknown as ClassModule.Section; - } - return section as unknown as ClassModule.Section; - }); + if (parent.sections) return parent.sections; + + const secondarySections = await getSecondarySections( + parent.year, + parent.semester, + parent.sessionId, + parent.subject, + parent.courseNumber, + parent.number + ); + + return secondarySections as unknown as ClassModule.Section[]; }, gradeDistribution: async ( @@ -289,17 +241,6 @@ const resolvers: ClassModule.Resolvers = { (attr) => attr.attribute?.code === args.attributeCode ); }, - - meetings: (parent: IntermediateSection | ClassModule.Section) => { - const meetings = parent.meetings ?? []; - - // Filter instructors to only show Primary Instructors (PI = professors) - // This prevents TAs from appearing in instructor lists across the application - return meetings.map((meeting) => ({ - ...meeting, - instructors: filterAndSortInstructors(meeting.instructors), - })); - }, }, // Session: { diff --git a/apps/backend/src/modules/collection/controller.ts b/apps/backend/src/modules/collection/controller.ts deleted file mode 100644 index 1d62736f6..000000000 --- a/apps/backend/src/modules/collection/controller.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { GraphQLError } from "graphql"; -import { Types } from "mongoose"; - -import { ClassModel, CollectionModel } from "@repo/common"; - -import { CollectionModule } from "./generated-types/module-types"; - -const MAX_PERSONAL_NOTE_LENGTH = 5000; - -export interface RequestContext { - user: { - _id?: string; - isAuthenticated: boolean; - }; -} - -// Type for stored class entries in MongoDB -export interface StoredClassEntry { - year: number; - semester: string; - sessionId: string; - subject: string; - courseNumber: string; - classNumber: string; - personalNote?: { - text: string; - updatedAt: Date; - }; -} - -// Type for collection documents returned from MongoDB -export interface CollectionDocument { - _id: Types.ObjectId; - createdBy: string; - name: string; - classes: StoredClassEntry[]; - createdAt: Date; - updatedAt: Date; -} - -// Collection-Level Operations - -export const getAllCollections = async ( - context: RequestContext -): Promise => { - if (!context.user._id) { - throw new GraphQLError("Unauthorized", { - extensions: { code: "UNAUTHENTICATED" }, - }); - } - - return (await CollectionModel.find({ - createdBy: context.user._id, - }).lean()) as unknown as CollectionDocument[]; -}; - -export const getCollection = async ( - context: RequestContext, - name: string -): Promise => { - if (!context.user._id) { - throw new GraphQLError("Unauthorized", { - extensions: { code: "UNAUTHENTICATED" }, - }); - } - - const collection = (await CollectionModel.findOne({ - createdBy: context.user._id, - name, - }).lean()) as CollectionDocument | null; - - if (!collection) { - throw new GraphQLError("Collection not found", { - extensions: { code: "NOT_FOUND" }, - }); - } - - return collection; -}; - -export const renameCollection = async ( - context: RequestContext, - oldName: string, - newName: string -): Promise => { - if (!context.user._id) { - throw new GraphQLError("Unauthorized", { - extensions: { code: "UNAUTHENTICATED" }, - }); - } - - // Validate new name - if (!newName || newName.trim().length === 0) { - throw new GraphQLError("New collection name cannot be empty", { - extensions: { code: "BAD_USER_INPUT" }, - }); - } - - // Find collection (with ownership verification) - const collection = await CollectionModel.findOne({ - createdBy: context.user._id, - name: oldName, - }); - - if (!collection) { - throw new GraphQLError("Collection not found", { - extensions: { code: "NOT_FOUND" }, - }); - } - - // Check new name doesn't conflict - const existing = await CollectionModel.findOne({ - createdBy: context.user._id, - name: newName, - }); - - if (existing) { - throw new GraphQLError(`Collection "${newName}" already exists`, { - extensions: { code: "BAD_USER_INPUT" }, - }); - } - - // Update name - collection.name = newName; - await collection.save(); - - return collection.toObject() as unknown as CollectionDocument; -}; - -export const deleteCollection = async ( - context: RequestContext, - name: string -): Promise => { - if (!context.user._id) { - throw new GraphQLError("Unauthorized", { - extensions: { code: "UNAUTHENTICATED" }, - }); - } - - const result = await CollectionModel.deleteOne({ - createdBy: context.user._id, - name, - }); - - if (result.deletedCount === 0) { - throw new GraphQLError("Collection not found", { - extensions: { code: "NOT_FOUND" }, - }); - } - - return true; -}; - -// Class-Level Operations - -export const addClassToCollection = async ( - context: RequestContext, - input: CollectionModule.AddClassInput -): Promise => { - if (!context.user._id) { - throw new GraphQLError("Unauthorized", { - extensions: { code: "UNAUTHENTICATED" }, - }); - } - - const { - collectionName, - year, - semester, - sessionId, - subject, - courseNumber, - classNumber, - personalNote, - } = input; - - // Validate collection name - if (!collectionName || collectionName.trim().length === 0) { - throw new GraphQLError("Collection name cannot be empty", { - extensions: { code: "BAD_USER_INPUT" }, - }); - } - - // Validate personal note length - if (personalNote && personalNote.text.length > MAX_PERSONAL_NOTE_LENGTH) { - throw new GraphQLError( - `Personal note cannot exceed ${MAX_PERSONAL_NOTE_LENGTH} characters`, - { extensions: { code: "BAD_USER_INPUT" } } - ); - } - - // Verify class exists in catalog - const classExists = await ClassModel.findOne({ - year, - semester, - sessionId, - subject, - courseNumber, - number: classNumber, - }); - - if (!classExists) { - throw new GraphQLError("Class not found in catalog", { - extensions: { code: "BAD_USER_INPUT" }, - }); - } - - // Normalize personalNote: treat empty/whitespace text as undefined - const normalizedPersonalNote = - personalNote && personalNote.text.trim().length > 0 - ? { - text: personalNote.text.trim(), - updatedAt: new Date(), - } - : undefined; - - const classIdentifier = { - year, - semester, - sessionId, - subject, - courseNumber, - classNumber, - }; - - // Try to update existing class's note in collection - let result = await CollectionModel.findOneAndUpdate( - { - createdBy: context.user._id, - name: collectionName, - classes: { $elemMatch: classIdentifier }, - }, - { - $set: { "classes.$.personalNote": normalizedPersonalNote }, - }, - { new: true } - ); - - if (result) { - return result.toObject() as unknown as CollectionDocument; - } - - // Add class to existing or new collection - result = await CollectionModel.findOneAndUpdate( - { - createdBy: context.user._id, - name: collectionName, - }, - { - $setOnInsert: { createdBy: context.user._id, name: collectionName }, - $push: { - classes: { ...classIdentifier, personalNote: normalizedPersonalNote }, - }, - }, - { upsert: true, new: true } - ); - - return result!.toObject() as unknown as CollectionDocument; -}; - -export const removeClassFromCollection = async ( - context: RequestContext, - input: CollectionModule.RemoveClassInput -): Promise => { - if (!context.user._id) { - throw new GraphQLError("Unauthorized", { - extensions: { code: "UNAUTHENTICATED" }, - }); - } - - const { - collectionName, - year, - semester, - sessionId, - subject, - courseNumber, - classNumber, - } = input; - - const classIdentifier = { - year, - semester, - sessionId, - subject, - courseNumber, - classNumber, - }; - - // Atomic operation: Remove class from collection using $pull - const result = await CollectionModel.findOneAndUpdate( - { - createdBy: context.user._id, - name: collectionName, - classes: { $elemMatch: classIdentifier }, - }, - { - $pull: { classes: classIdentifier }, - }, - { new: true } - ); - - if (!result) { - // Determine if collection doesn't exist or class wasn't in collection - const collectionExists = await CollectionModel.findOne({ - createdBy: context.user._id, - name: collectionName, - }); - - if (!collectionExists) { - throw new GraphQLError("Collection not found", { - extensions: { code: "NOT_FOUND" }, - }); - } - - throw new GraphQLError("Class not found in collection", { - extensions: { code: "NOT_FOUND" }, - }); - } - - return result.toObject() as unknown as CollectionDocument; -}; diff --git a/apps/backend/src/modules/collection/index.ts b/apps/backend/src/modules/collection/index.ts deleted file mode 100644 index 3dfb28d9e..000000000 --- a/apps/backend/src/modules/collection/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { collectionTypeDef } from "@repo/gql-typedefs"; - -import resolver from "./resolver"; - -export default { - resolver, - typeDef: collectionTypeDef, -}; diff --git a/apps/backend/src/modules/collection/resolver.ts b/apps/backend/src/modules/collection/resolver.ts deleted file mode 100644 index 577fc4d19..000000000 --- a/apps/backend/src/modules/collection/resolver.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { GraphQLError } from "graphql"; - -import { ClassModel } from "@repo/common"; - -import { CollectionDocument, StoredClassEntry } from "./controller"; -import * as controller from "./controller"; -import { CollectionModule } from "./generated-types/module-types"; - -// Intermediate type for parent passed to Collection field resolvers -// Note: classes is StoredClassEntry[] here, transformed by Collection.classes resolver -interface CollectionParent { - _id: string; - createdBy: string; - name: string; - classes: StoredClassEntry[]; - createdAt: string; - updatedAt: string; -} - -// Helper to map Collection document to GraphQL response -// Returns CollectionParent which will be resolved by field resolvers -const mapCollectionToGraphQL = ( - collection: CollectionDocument -): CollectionModule.Collection => { - const parent: CollectionParent = { - _id: collection._id.toString(), - createdBy: collection.createdBy, - name: collection.name, - classes: collection.classes, - createdAt: - collection.createdAt instanceof Date - ? collection.createdAt.toISOString() - : String(collection.createdAt), - updatedAt: - collection.updatedAt instanceof Date - ? collection.updatedAt.toISOString() - : String(collection.updatedAt), - }; - // Cast is safe: Collection.classes resolver transforms StoredClassEntry[] to CollectionClass[] - return parent as unknown as CollectionModule.Collection; -}; - -const resolvers: CollectionModule.Resolvers = { - Query: { - myCollections: async (_, __, context) => { - try { - const collections = await controller.getAllCollections(context); - return collections.map(mapCollectionToGraphQL); - } catch (error: unknown) { - if (error instanceof GraphQLError) { - throw error; - } - throw new GraphQLError( - typeof error === "object" && error !== null && "message" in error - ? String(error.message) - : "An unexpected error occurred", - { extensions: { code: "INTERNAL_SERVER_ERROR" } } - ); - } - }, - - myCollection: async (_, { name }, context) => { - try { - const collection = await controller.getCollection(context, name); - if (!collection) { - return null; - } - return mapCollectionToGraphQL(collection); - } catch (error: unknown) { - if (error instanceof GraphQLError) { - throw error; - } - throw new GraphQLError( - typeof error === "object" && error !== null && "message" in error - ? String(error.message) - : "An unexpected error occurred", - { extensions: { code: "INTERNAL_SERVER_ERROR" } } - ); - } - }, - }, - - Mutation: { - renameCollection: async (_, { oldName, newName }, context) => { - try { - const collection = await controller.renameCollection( - context, - oldName, - newName - ); - return mapCollectionToGraphQL(collection); - } catch (error: unknown) { - if (error instanceof GraphQLError) { - throw error; - } - throw new GraphQLError( - typeof error === "object" && error !== null && "message" in error - ? String(error.message) - : "An unexpected error occurred", - { extensions: { code: "INTERNAL_SERVER_ERROR" } } - ); - } - }, - - deleteCollection: async (_, { name }, context) => { - try { - return await controller.deleteCollection(context, name); - } catch (error: unknown) { - if (error instanceof GraphQLError) { - throw error; - } - throw new GraphQLError( - typeof error === "object" && error !== null && "message" in error - ? String(error.message) - : "An unexpected error occurred", - { extensions: { code: "INTERNAL_SERVER_ERROR" } } - ); - } - }, - - addClassToCollection: async (_, { input }, context) => { - try { - const collection = await controller.addClassToCollection( - context, - input - ); - return mapCollectionToGraphQL(collection); - } catch (error: unknown) { - if (error instanceof GraphQLError) { - throw error; - } - throw new GraphQLError( - typeof error === "object" && error !== null && "message" in error - ? String(error.message) - : "An unexpected error occurred", - { extensions: { code: "INTERNAL_SERVER_ERROR" } } - ); - } - }, - - removeClassFromCollection: async (_, { input }, context) => { - try { - const collection = await controller.removeClassFromCollection( - context, - input - ); - return mapCollectionToGraphQL(collection); - } catch (error: unknown) { - if (error instanceof GraphQLError) { - throw error; - } - throw new GraphQLError( - typeof error === "object" && error !== null && "message" in error - ? String(error.message) - : "An unexpected error occurred", - { extensions: { code: "INTERNAL_SERVER_ERROR" } } - ); - } - }, - }, - - Collection: { - // Resolve classes with their full class info - classes: async (parent) => { - const typedParent = parent as CollectionParent; - - // Guard: Return early if no classes in collection - if (!typedParent.classes || typedParent.classes.length === 0) { - return []; - } - - // Build batch query for all classes - const classQueries = typedParent.classes.map((classEntry) => ({ - year: classEntry.year, - semester: classEntry.semester, - sessionId: classEntry.sessionId, - subject: classEntry.subject, - courseNumber: classEntry.courseNumber, - number: classEntry.classNumber, // Note: classNumber -> number - })); - - const allClasses = await ClassModel.find({ - $or: classQueries, - }).lean(); - - const classMap = new Map( - allClasses.map((c) => [ - `${c.year}|${c.semester}|${c.sessionId}|${c.subject}|${c.courseNumber}|${c.number}`, - c, - ]) - ); - - // Helper to format personal note - const formatPersonalNote = (note: StoredClassEntry["personalNote"]) => - note - ? { - text: note.text, - updatedAt: - note.updatedAt instanceof Date - ? note.updatedAt.toISOString() - : String(note.updatedAt), - } - : null; - - return typedParent.classes.map( - (classEntry): CollectionModule.CollectionClass => { - const key = `${classEntry.year}|${classEntry.semester}|${classEntry.sessionId}|${classEntry.subject}|${classEntry.courseNumber}|${classEntry.classNumber}`; - const classData = classMap.get(key); - - if (!classData) { - console.warn(`Class not found in catalog:`, { - year: classEntry.year, - semester: classEntry.semester, - sessionId: classEntry.sessionId, - subject: classEntry.subject, - courseNumber: classEntry.courseNumber, - number: classEntry.classNumber, - }); - - return { - class: null, - personalNote: formatPersonalNote(classEntry.personalNote), - error: "CLASS_NOT_FOUND_IN_CATALOG", - }; - } - - return { - // Cast to Class - nested fields resolved by Class field resolvers - class: classData as unknown as CollectionModule.Class, - personalNote: formatPersonalNote(classEntry.personalNote), - error: null, - }; - } - ); - }, - }, -}; - -export default resolvers; diff --git a/apps/backend/src/modules/course/controller.ts b/apps/backend/src/modules/course/controller.ts index bbe0e607f..c2184648c 100644 --- a/apps/backend/src/modules/course/controller.ts +++ b/apps/backend/src/modules/course/controller.ts @@ -1,15 +1,11 @@ import { ClassModel, CourseModel, IClassItem, ICourseItem } from "@repo/common"; -import { buildSubjectQuery } from "../../utils/subject"; import { formatClass } from "../class/formatter"; import { IntermediateCourse, formatCourse } from "./formatter"; import { CourseModule } from "./generated-types/module-types"; export const getCourse = async (subject: string, number: string) => { - const course = await CourseModel.findOne({ - subject: buildSubjectQuery(subject), - number, - }) + const course = await CourseModel.findOne({ subject, number }) .sort({ fromDate: -1 }) .lean(); diff --git a/apps/backend/src/modules/course/formatter.ts b/apps/backend/src/modules/course/formatter.ts index 1114441fc..383893d17 100644 --- a/apps/backend/src/modules/course/formatter.ts +++ b/apps/backend/src/modules/course/formatter.ts @@ -1,6 +1,5 @@ import { ICourseItem } from "@repo/common"; -import { normalizeSubject } from "../../utils/subject"; import { CourseModule } from "./generated-types/module-types"; interface CourseRelationships { @@ -10,23 +9,15 @@ interface CourseRelationships { requiredCourses: string[]; } -interface CourseComputedFields { - allTimeAverageGrade: number | null; - allTimePassCount: number | null; - allTimeNoPassCount: number | null; -} - export type IntermediateCourse = Omit< CourseModule.Course, keyof CourseRelationships > & - CourseRelationships & - CourseComputedFields; + CourseRelationships; export function formatCourse(course: ICourseItem) { const output = { ...course, - subject: normalizeSubject(course.subject), gradingBasis: course.gradingBasis as CourseModule.CourseGradingBasis, finalExam: course.finalExam as CourseModule.CourseFinalExam, @@ -35,9 +26,6 @@ export function formatCourse(course: ICourseItem) { crossListing: course.crossListing ?? [], requiredCourses: course.preparation?.requiredCourses ?? [], requirements: course.preparation?.requiredText ?? null, - allTimeAverageGrade: course.allTimeAverageGrade ?? null, - allTimePassCount: course.allTimePassCount ?? null, - allTimeNoPassCount: course.allTimeNoPassCount ?? null, } as IntermediateCourse; return output; diff --git a/apps/backend/src/modules/course/resolver.ts b/apps/backend/src/modules/course/resolver.ts index ea480d6a6..52b61ce16 100644 --- a/apps/backend/src/modules/course/resolver.ts +++ b/apps/backend/src/modules/course/resolver.ts @@ -1,14 +1,7 @@ import { GraphQLError, GraphQLScalarType, Kind } from "graphql"; -import { getPnpPercentageFromCounts } from "@repo/common"; - -import { CourseAggregatedRatingsArgs } from "../../generated-types/graphql"; -import { getFields } from "../../utils/graphql"; import { getGradeDistributionByCourse } from "../grade-distribution/controller"; -import { - getCourseAggregatedRatings, - getInstructorAggregatedRatings, -} from "../rating/controller"; +import { getCourseAggregatedRatings } from "../rating/controller"; import { getAssociatedCoursesById, getAssociatedCoursesBySubjectNumber, @@ -106,41 +99,9 @@ const resolvers: CourseModule.Resolvers = { }, gradeDistribution: async ( - parent: IntermediateCourse | CourseModule.Course, - _args, - _context, - info + parent: IntermediateCourse | CourseModule.Course ) => { - const requestedFields = getFields(info.fieldNodes); - const needsDistribution = requestedFields.includes("distribution"); - - if (!needsDistribution) { - if (parent.gradeDistribution) { - return { - ...parent.gradeDistribution, - distribution: [], - }; - } - - const { allTimeAverageGrade, allTimePassCount, allTimeNoPassCount } = - parent as IntermediateCourse; - - return { - average: allTimeAverageGrade ?? null, - distribution: [], - pnpPercentage: getPnpPercentageFromCounts( - allTimePassCount, - allTimeNoPassCount - ), - }; - } - - if ( - parent.gradeDistribution && - parent.gradeDistribution.distribution && - parent.gradeDistribution.distribution.length > 0 - ) - return parent.gradeDistribution; + if (parent.gradeDistribution) return parent.gradeDistribution; const gradeDistribution = await getGradeDistributionByCourse( parent.subject, @@ -151,27 +112,14 @@ const resolvers: CourseModule.Resolvers = { }, aggregatedRatings: async ( - parent: IntermediateCourse | CourseModule.Course, - args: CourseAggregatedRatingsArgs - ) => { - const aggregatedRatings = await getCourseAggregatedRatings( - parent.subject, - parent.number, - args.metricNames ?? undefined - ); - - return aggregatedRatings; - }, - - instructorAggregatedRatings: async ( parent: IntermediateCourse | CourseModule.Course ) => { - const instructorRatings = await getInstructorAggregatedRatings( + const aggregatedRatings = await getCourseAggregatedRatings( parent.subject, parent.number ); - return instructorRatings; + return aggregatedRatings; }, }, diff --git a/apps/backend/src/modules/enrollment/controller.ts b/apps/backend/src/modules/enrollment/controller.ts index b5e5a316e..7ba5a911d 100644 --- a/apps/backend/src/modules/enrollment/controller.ts +++ b/apps/backend/src/modules/enrollment/controller.ts @@ -1,7 +1,6 @@ import { NewEnrollmentHistoryModel } from "@repo/common"; import { Semester } from "../../generated-types/graphql"; -import { buildSubjectQuery } from "../../utils/subject"; import { formatEnrollment } from "./formatter"; export const getEnrollment = async ( @@ -12,13 +11,11 @@ export const getEnrollment = async ( courseNumber: string, sectionNumber: string ) => { - const subjectQuery = buildSubjectQuery(subject); - const enrollment = await NewEnrollmentHistoryModel.findOne({ year, semester, sessionId: sessionId ? sessionId : "1", - subject: subjectQuery, + subject, courseNumber, sectionNumber, }).lean(); diff --git a/apps/backend/src/modules/enrollment/formatter.ts b/apps/backend/src/modules/enrollment/formatter.ts index 5e378fdda..028df9a8a 100644 --- a/apps/backend/src/modules/enrollment/formatter.ts +++ b/apps/backend/src/modules/enrollment/formatter.ts @@ -4,35 +4,7 @@ import { EnrollmentModule } from "./generated-types/module-types"; type EnrollmentHistorySingular = IEnrollmentHistoryItem["history"][number]; -const normalizeEnrollmentSingular = ( - singular: EnrollmentHistorySingular, - seatReservationTypes: IEnrollmentHistoryItem["seatReservationTypes"] -) => { - const types = seatReservationTypes ?? []; - const counts = singular.seatReservationCount ?? []; - const now = new Date(); - - // Join seatReservationCount with seatReservationTypes by number - const enrichedSeatReservationCount = counts.map((count) => { - const matchingType = types.find((type) => type.number === count.number); - const maxEnroll = count.maxEnroll ?? 0; - const fromDate = matchingType?.fromDate ?? ""; - const fromDateObj = fromDate ? new Date(fromDate) : null; - - // Validation logic: maxEnroll > 1 AND reservation period has started - const isValid = maxEnroll > 1 && (!fromDateObj || fromDateObj <= now); - - return { - ...count, - requirementGroup: { - code: matchingType?.requirementGroup?.code ?? null, - description: matchingType?.requirementGroup?.description ?? "Unknown", - }, - fromDate, - isValid, - }; - }); - +const normalizeEnrollmentSingular = (singular: EnrollmentHistorySingular) => { const normalized = { ...singular, startTime: singular.startTime.toISOString(), @@ -44,22 +16,18 @@ const normalizeEnrollmentSingular = ( maxEnroll: singular.maxEnroll ?? 0, maxWaitlist: singular.maxWaitlist ?? 0, openReserved: singular.openReserved ?? 0, - seatReservationCount: enrichedSeatReservationCount, - } as any as EnrollmentModule.EnrollmentSingular; + seatReservationCount: singular.seatReservationCount ?? [], + } as EnrollmentModule.EnrollmentSingular; return normalized; }; export const formatEnrollment = (enrollment: IEnrollmentHistoryItem) => { - const seatReservationTypes = enrollment.seatReservationTypes ?? []; - const history = - enrollment.history?.map((singular) => - normalizeEnrollmentSingular(singular, seatReservationTypes) - ) ?? []; + const history = enrollment.history?.map(normalizeEnrollmentSingular) ?? []; const output = { ...enrollment, - seatReservationTypes: [], // No longer expose this at the API level + seatReservationTypes: enrollment.seatReservationTypes ?? [], history, latest: history.length > 0 ? history[history.length - 1] : null, diff --git a/apps/backend/src/modules/enrollment/resolver.ts b/apps/backend/src/modules/enrollment/resolver.ts index ffb4cbfbc..d7229dab4 100644 --- a/apps/backend/src/modules/enrollment/resolver.ts +++ b/apps/backend/src/modules/enrollment/resolver.ts @@ -1,5 +1,3 @@ -import { EnrollmentTimeframeModel } from "@repo/common"; - import { getEnrollment } from "./controller"; import { EnrollmentModule } from "./generated-types/module-types"; @@ -18,30 +16,14 @@ const resolvers: EnrollmentModule.Resolvers = { sectionNumber ); }, - enrollmentTimeframes: async (_, { year, semester }) => { - const timeframes = await EnrollmentTimeframeModel.find({ - year, - semester, - }).lean(); - - return timeframes.map((tf) => ({ - phase: tf.phase ?? null, - isAdjustment: tf.isAdjustment, - group: tf.group, - startDate: tf.startDate.toISOString(), - endDate: tf.endDate?.toISOString() ?? null, - startEventSummary: tf.startEventSummary ?? null, - })); - }, }, EnrollmentSingular: { - activeReservedMaxCount: (parent) => { + reservedSeatingMaxCount: (parent) => { const seatReservations = parent.seatReservationCount ?? []; - return seatReservations.reduce((sum, reservation) => { - const isValid = (reservation as any).isValid ?? false; - const maxEnroll = reservation.maxEnroll ?? 0; - return sum + (isValid ? maxEnroll : 0); - }, 0); + return seatReservations.reduce( + (sum, reservation) => sum + (reservation.maxEnroll ?? 0), + 0 + ); }, }, }; diff --git a/apps/backend/src/modules/grade-distribution/controller.ts b/apps/backend/src/modules/grade-distribution/controller.ts index 5ff2b5720..cbbb90304 100644 --- a/apps/backend/src/modules/grade-distribution/controller.ts +++ b/apps/backend/src/modules/grade-distribution/controller.ts @@ -1,22 +1,201 @@ import { GradeDistributionModel, + IGradeDistributionItem, SectionModel, TermModel, - getAverageGrade, - getDistribution, - getPnpPercentage, } from "@repo/common"; -import { buildSubjectQuery } from "../../utils/subject"; +enum Letter { + APlus = "A+", + A = "A", + AMinus = "A-", + BPlus = "B+", + B = "B", + BMinus = "B-", + CPlus = "C+", + C = "C", + CMinus = "C-", + DPlus = "D+", + D = "D", + DMinus = "D-", + F = "F", + NotPass = "NP", + Pass = "P", + Unsatisfactory = "U", + Satisfactory = "S", +} + +interface Grade { + letter: Letter; + percentage: number; + count: number; +} + +export const getDistribution = (distributions: IGradeDistributionItem[]) => { + const distribution = distributions.reduce( + ( + acc, + { + countA, + countAMinus, + countAPlus, + countB, + countBMinus, + countBPlus, + countC, + countCMinus, + countCPlus, + countD, + countDMinus, + countDPlus, + countF, + countNP, + countP, + countU, + countS, + } + ) => { + acc.countA += countA; + acc.countAMinus += countAMinus; + acc.countAPlus += countAPlus; + acc.countB += countB; + acc.countBMinus += countBMinus; + acc.countBPlus += countBPlus; + acc.countC += countC; + acc.countCMinus += countCMinus; + acc.countCPlus += countCPlus; + acc.countD += countD; + acc.countDMinus += countDMinus; + acc.countDPlus += countDPlus; + acc.countF += countF; + acc.countNP += countNP; + acc.countP += countP; + acc.countU += countU; + acc.countS += countS; + return acc; + }, + { + countA: 0, + countAMinus: 0, + countAPlus: 0, + countB: 0, + countBMinus: 0, + countBPlus: 0, + countC: 0, + countCMinus: 0, + countCPlus: 0, + countD: 0, + countDMinus: 0, + countDPlus: 0, + countF: 0, + countNP: 0, + countP: 0, + countU: 0, + countS: 0, + } + ); + + const total = Object.values(distribution).reduce((acc, count) => acc + count); + + return Object.entries(distribution).map( + ([field, count]) => + ({ + letter: letters[field], + percentage: count > 0 ? count / total : 0, + count, + }) as Grade + ); +}; + +export const letters: { [key: string]: string } = { + countAPlus: "A+", + countA: "A", + countAMinus: "A-", + countBPlus: "B+", + countB: "B", + countBMinus: "B-", + countCPlus: "C+", + countC: "C", + countCMinus: "C-", + countDPlus: "D+", + countD: "D", + countDMinus: "D-", + countF: "F", + countNP: "NP", + countP: "P", + countU: "U", + countS: "S", +}; + +export const points: { [key: string]: number } = { + A: 4, + "A-": 3.7, + "A+": 4, + B: 3, + "B-": 2.7, + "B+": 3.3, + C: 2, + "C-": 1.7, + "C+": 2.3, + D: 1, + "D-": 0.7, + "D+": 1.3, + F: 0, +}; + +export const getAverageGrade = (distribution: Grade[]) => { + const total = distribution.reduce((acc, { letter, count }) => { + if (Object.keys(points).includes(letter)) return acc + count; + + // Ignore letters not included in GPA + return acc; + }, 0); + + // For distributions without a GPA, return null + if (total === 0) return null; + + const weightedTotal = distribution.reduce((acc, { letter, count }) => { + if (Object.keys(points).includes(letter)) + return points[letter] * count + acc; + + return acc; + }, 0); + + return weightedTotal / total; +}; + +export const getPnpPercentage = (distribution: Grade[]) => { + // Calculate (P + S) / (P + NP + S + U) + const pnpGrades = distribution.reduce( + (acc, { letter, count }) => { + if (letter === Letter.Pass) acc.pass += count; + else if (letter === Letter.Satisfactory) acc.satisfactory += count; + else if (letter === Letter.NotPass) acc.notPass += count; + else if (letter === Letter.Unsatisfactory) acc.unsatisfactory += count; + return acc; + }, + { pass: 0, satisfactory: 0, notPass: 0, unsatisfactory: 0 } + ); + + const totalPnp = + pnpGrades.pass + + pnpGrades.satisfactory + + pnpGrades.notPass + + pnpGrades.unsatisfactory; + + // If there are no PNP grades, return null + if (totalPnp === 0) return null; + + const passingPnp = pnpGrades.pass + pnpGrades.satisfactory; + return passingPnp / totalPnp; +}; export const getGradeDistributionByCourse = async ( subject: string, number: string ) => { - const subjectQuery = buildSubjectQuery(subject); - const distributions = await GradeDistributionModel.find({ - subject: subjectQuery, + subject, courseNumber: number, }); @@ -40,13 +219,11 @@ export const getGradeDistributionByClass = async ( courseNumber: string, sectionNumber: string ) => { - const subjectQuery = buildSubjectQuery(subject); - const section = await SectionModel.findOne({ year, semester, sessionId, - subject: subjectQuery, + subject, courseNumber, number: sectionNumber, primary: true, @@ -57,7 +234,7 @@ export const getGradeDistributionByClass = async ( if (!section) throw new Error("Class not found"); const distributions = await GradeDistributionModel.find({ - subject: subjectQuery, + subject, courseNumber, sectionId: section.sectionId, }); @@ -81,8 +258,6 @@ export const getGradeDistributionBySemester = async ( subject: string, courseNumber: string ) => { - const subjectQuery = buildSubjectQuery(subject); - const term = await TermModel.findOne({ name: `${year} ${semester}`, }) @@ -94,7 +269,7 @@ export const getGradeDistributionBySemester = async ( const distributions = await GradeDistributionModel.find({ termId: term.id, sessionId, - subject: subjectQuery, + subject, courseNumber, }); @@ -116,10 +291,8 @@ export const getGradeDistributionByInstructor = async ( familyName: string, givenName: string ) => { - const subjectQuery = buildSubjectQuery(subject); - const sections = await SectionModel.find({ - subject: subjectQuery, + subject, courseNumber, "meetings.instructors.familyName": familyName, "meetings.instructors.givenName": givenName, @@ -156,13 +329,11 @@ export const getGradeDistributionByInstructorAndSemester = async ( familyName: string, givenName: string ) => { - const subjectQuery = buildSubjectQuery(subject); - const sections = await SectionModel.find({ year, semester, sessionId, - subject: subjectQuery, + subject, courseNumber, "meetings.instructors.familyName": familyName, "meetings.instructors.givenName": givenName, diff --git a/apps/backend/src/modules/index.ts b/apps/backend/src/modules/index.ts index a0e07bf88..8966140ce 100644 --- a/apps/backend/src/modules/index.ts +++ b/apps/backend/src/modules/index.ts @@ -2,7 +2,6 @@ import { merge } from "lodash"; import Catalog from "./catalog"; import Class from "./class"; -import Collection from "./collection"; import Common from "./common"; import Course from "./course"; import CuratedClasses from "./curated-classes"; @@ -19,7 +18,6 @@ const modules = [ GradeDistribution, Catalog, CuratedClasses, - Collection, Common, Schedule, Term, diff --git a/apps/backend/src/modules/rating/controller.ts b/apps/backend/src/modules/rating/controller.ts index b2e5713b0..8af1a2a1d 100644 --- a/apps/backend/src/modules/rating/controller.ts +++ b/apps/backend/src/modules/rating/controller.ts @@ -1,13 +1,8 @@ import { GraphQLError } from "graphql"; import { connection } from "mongoose"; -import { - AggregatedMetricsModel, - RatingModel, - RatingType, - SectionModel, -} from "@repo/common"; -import { METRIC_MAPPINGS, REQUIRED_METRICS } from "@repo/shared"; +import { AggregatedMetricsModel, RatingModel, RatingType } from "@repo/common"; +import { METRIC_MAPPINGS } from "@repo/shared"; import { InputMaybe, @@ -22,7 +17,6 @@ import { } from "./formatter"; import { courseRatingAggregator, - instructorRatingsAggregator, ratingAggregator, semestersWithRatingsAggregator, termRatingsAggregator, @@ -332,30 +326,12 @@ export const getUserRatings = async (context: RequestContext) => { return formatUserRatings(userRatings[0]); }; -const filterAggregatedMetrics = ( - aggregated: ReturnType, - metricNames?: InputMaybe -) => { - if (!metricNames || metricNames.length === 0) { - return aggregated; - } - - const allowedMetrics = new Set(metricNames); - return { - ...aggregated, - metrics: aggregated.metrics.filter((metric) => - allowedMetrics.has(metric.metricName as MetricName) - ), - }; -}; - export const getClassAggregatedRatings = async ( year: number, semester: Semester, subject: string, courseNumber: string, - classNumber?: InputMaybe, - metricNames?: InputMaybe + classNumber?: InputMaybe ) => { const aggregated = classNumber ? await ratingAggregator({ @@ -376,16 +352,12 @@ export const getClassAggregatedRatings = async ( metrics: [], }; - return filterAggregatedMetrics( - formatAggregatedRatings(aggregated[0]), - metricNames - ); + return formatAggregatedRatings(aggregated[0]); }; export const getCourseAggregatedRatings = async ( subject: string, - courseNumber: string, - metricNames?: InputMaybe + courseNumber: string ) => { const aggregated = await courseRatingAggregator(subject, courseNumber); @@ -401,7 +373,7 @@ export const getCourseAggregatedRatings = async ( } const formattedResult = formatAggregatedRatings(aggregated[0]); - return filterAggregatedMetrics(formattedResult, metricNames); + return formattedResult; }; export const getSemestersWithRatings = async ( @@ -412,99 +384,6 @@ export const getSemestersWithRatings = async ( return formatSemesterRatings(semesters); }; -export const getInstructorAggregatedRatings = async ( - subject: string, - courseNumber: string -) => { - // Find all sections for this course - const sections = await SectionModel.find({ - subject, - courseNumber, - }).select("semester year number classNumber meetings"); - - // Build a map of instructors to the classes they taught - const instructorMap = new Map< - string, - { - givenName: string; - familyName: string; - classes: { semester: Semester; year: number; classNumber: string }[]; - } - >(); - - sections.forEach((section) => { - section.meetings?.forEach((meeting) => { - meeting.instructors?.forEach((instructor) => { - // Only include Primary Instructors (PI role) - if ( - instructor.givenName && - instructor.familyName && - instructor.role === "PI" - ) { - const key = `${instructor.givenName}_${instructor.familyName}`; - - if (!instructorMap.has(key)) { - instructorMap.set(key, { - givenName: instructor.givenName, - familyName: instructor.familyName, - classes: [], - }); - } - - const instructorData = instructorMap.get(key)!; - const classId = { - semester: section.semester as Semester, - year: section.year, - classNumber: section.classNumber ?? section.number, - }; - - // Avoid duplicates - const exists = instructorData.classes.some( - (c) => - c.semester === classId.semester && - c.year === classId.year && - c.classNumber === classId.classNumber - ); - - if (!exists) { - instructorData.classes.push(classId); - } - } - }); - }); - }); - - // For each instructor, aggregate their ratings - const instructorRatings = await Promise.all( - Array.from(instructorMap.entries()).map(async ([_key, instructorData]) => { - const aggregated = await instructorRatingsAggregator( - subject, - courseNumber, - instructorData.classes - ); - - return { - instructor: { - givenName: instructorData.givenName, - familyName: instructorData.familyName, - }, - aggregatedRatings: formatAggregatedRatings(aggregated), - classesTaught: instructorData.classes, - }; - }) - ); - - // Only return instructors who have ratings - const instructorsWithRatings = instructorRatings.filter((rating) => { - const hasRatings = rating.aggregatedRatings.metrics.some( - (metric) => metric && metric.count > 0 - ); - return hasRatings; - }); - - return instructorsWithRatings; -}; - // Helper functions const createNewRating = async ( @@ -650,169 +529,3 @@ const handleCategoryCountChange = async ( ); } }; - -interface MetricInput { - metricName: MetricName; - value: number; -} - -export const createRatings = async ( - context: RequestContext, - year: number, - semester: Semester, - subject: string, - courseNumber: string, - classNumber: string, - metrics: MetricInput[] -) => { - if (!context.user._id) { - throw new GraphQLError("Unauthorized", { - extensions: { code: "UNAUTHENTICATED" }, - }); - } - - // Validate required metrics are present - const providedMetrics = new Set(metrics.map((m) => m.metricName)); - const missingRequired = REQUIRED_METRICS.filter( - (m) => !providedMetrics.has(m as MetricName) - ); - if (missingRequired.length > 0) { - throw new GraphQLError( - `Missing required metrics: ${missingRequired.join(", ")}`, - { - extensions: { code: "BAD_USER_INPUT" }, - } - ); - } - - // Validate all metric values - for (const metric of metrics) { - checkValueConstraint(metric.metricName, metric.value); - } - - // Get current user ratings for constraint checking - const userRatings = await getUserRatings(context); - checkUserMaxRatingsConstraint( - userRatings, - year, - semester as Semester, - subject, - courseNumber - ); - - // Find all existing ratings for this course by this user - const existingRatings = await RatingModel.find({ - createdBy: context.user._id, - subject, - courseNumber, - }); - - const session = await connection.startSession(); - try { - await session.withTransaction(async () => { - // Step 1: Delete all existing ratings and decrement their aggregated counts - for (const existingRating of existingRatings) { - await Promise.all([ - RatingModel.deleteOne({ _id: existingRating._id }, { session }), - handleCategoryCountChange( - Number(existingRating.year), - existingRating.semester as Semester, - existingRating.subject, - existingRating.courseNumber, - existingRating.classNumber, - existingRating.metricName as MetricName, - existingRating.value, - false, - session - ), - ]); - } - - // Step 2: Create all new ratings and increment their aggregated counts - for (const metric of metrics) { - await Promise.all([ - RatingModel.create( - [ - { - createdBy: context.user._id, - subject, - courseNumber, - semester, - year, - classNumber, - metricName: metric.metricName, - value: metric.value, - }, - ], - { session } - ), - handleCategoryCountChange( - year, - semester, - subject, - courseNumber, - classNumber, - metric.metricName, - metric.value, - true, - session - ), - ]); - } - }); - } finally { - await session.endSession(); - } - - return true; -}; - -export const deleteRatings = async ( - context: RequestContext, - subject: string, - courseNumber: string -) => { - if (!context.user._id) { - throw new GraphQLError("Unauthorized", { - extensions: { code: "UNAUTHENTICATED" }, - }); - } - - // Find all existing ratings for this course by this user - const existingRatings = await RatingModel.find({ - createdBy: context.user._id, - subject, - courseNumber, - }); - - if (existingRatings.length === 0) { - return true; // Nothing to delete - } - - const session = await connection.startSession(); - try { - await session.withTransaction(async () => { - // Delete all ratings and decrement their aggregated counts - for (const existingRating of existingRatings) { - await Promise.all([ - RatingModel.deleteOne({ _id: existingRating._id }, { session }), - handleCategoryCountChange( - Number(existingRating.year), - existingRating.semester as Semester, - existingRating.subject, - existingRating.courseNumber, - existingRating.classNumber, - existingRating.metricName as MetricName, - existingRating.value, - false, - session - ), - ]); - } - }); - } finally { - await session.endSession(); - } - - return true; -}; diff --git a/apps/backend/src/modules/rating/formatter.ts b/apps/backend/src/modules/rating/formatter.ts index d3724ae42..08f145871 100644 --- a/apps/backend/src/modules/rating/formatter.ts +++ b/apps/backend/src/modules/rating/formatter.ts @@ -10,17 +10,10 @@ import { UserRatings, } from "../../generated-types/graphql"; -const clampCount = (count: number | null | undefined): number => { - if (typeof count !== "number" || !Number.isFinite(count)) { - return 0; - } - return Math.max(count, 0); -}; - export const formatUserRatings = (ratings: UserRatings): UserRatings => { return { createdBy: ratings.createdBy, - count: clampCount(ratings.count), + count: ratings.count, classes: ratings.classes.map((userClass: UserClass) => ({ year: userClass.year, @@ -49,34 +42,16 @@ export const formatAggregatedRatings = ( courseNumber: aggregated.courseNumber, classNumber: aggregated.classNumber, - metrics: (aggregated.metrics ?? []).map((metric: Metric) => { - const categories = (metric.categories ?? []).map( - (category: Category) => ({ - value: category.value, - count: clampCount(category.count), - }) - ); - - const totalCount = categories.reduce( - (sum, category) => sum + category.count, - 0 - ); - const weightedSum = categories.reduce( - (sum, category) => - sum + - category.count * - (typeof category.value === "number" ? category.value : 0), - 0 - ); - const sanitizedCount = Math.max(totalCount, clampCount(metric.count)); + metrics: aggregated.metrics.map((metric: Metric) => ({ + metricName: metric.metricName as MetricName, + count: metric.count, + weightedAverage: metric.weightedAverage, - return { - metricName: metric.metricName as MetricName, - count: sanitizedCount, - weightedAverage: totalCount > 0 ? weightedSum / totalCount : 0, - categories, - }; - }), + categories: metric.categories.map((category: Category) => ({ + value: category.value, + count: category.count, + })), + })), }; }; @@ -99,6 +74,6 @@ export const formatSemesterRatings = (semesters: any[]): SemesterRatings[] => { return semesters.map((semester) => ({ year: semester.year, semester: semester.semester as Semester, - maxMetricCount: clampCount(semester.maxMetricCount), + maxMetricCount: semester.maxMetricCount, })); }; diff --git a/apps/backend/src/modules/rating/helper/aggregator.ts b/apps/backend/src/modules/rating/helper/aggregator.ts index 11d9b44f9..3c40cce82 100644 --- a/apps/backend/src/modules/rating/helper/aggregator.ts +++ b/apps/backend/src/modules/rating/helper/aggregator.ts @@ -464,121 +464,3 @@ export const semestersWithRatingsAggregator = async ( }, ]); }; - -export const instructorRatingsAggregator = async ( - subject: string, - courseNumber: string, - classes: { semester: Semester; year: number; classNumber: string }[] -) => { - if (classes.length === 0) { - return { - subject, - courseNumber, - metrics: [], - }; - } - - // Group classes by semester/year for more efficient querying - const semesterYearMap = new Map>(); - - classes.forEach(({ semester, year, classNumber }) => { - const key = `${semester}_${year}`; - if (!semesterYearMap.has(key)) { - semesterYearMap.set(key, new Set()); - } - semesterYearMap.get(key)!.add(classNumber); - }); - - // Build $or conditions with $in for classNumbers - const orConditions = Array.from(semesterYearMap.entries()).map( - ([semesterYear, classNumbers]) => { - const [semester, year] = semesterYear.split("_"); - return { - semester, - year: parseInt(year), - classNumber: { $in: Array.from(classNumbers) }, - }; - } - ); - - const result = await AggregatedMetricsModel.aggregate([ - { - $match: { - subject, - courseNumber, - $or: orConditions, - }, - }, - { - $group: { - _id: { - subject: "$subject", - courseNumber: "$courseNumber", - metricName: "$metricName", - categoryValue: "$categoryValue", - }, - categoryCount: { $sum: "$categoryCount" }, - }, - }, - { - $group: { - _id: { - subject: "$_id.subject", - courseNumber: "$_id.courseNumber", - metricName: "$_id.metricName", - }, - totalCount: { $sum: "$categoryCount" }, - sumValues: { - $sum: { $multiply: ["$_id.categoryValue", "$categoryCount"] }, - }, - categories: { - $push: { - value: "$_id.categoryValue", - count: "$categoryCount", - }, - }, - }, - }, - { - $group: { - _id: { - subject: "$_id.subject", - courseNumber: "$_id.courseNumber", - }, - metrics: { - $push: { - metricName: "$_id.metricName", - count: "$totalCount", - weightedAverage: { - $cond: [ - { $eq: ["$totalCount", 0] }, - 0, - { $divide: ["$sumValues", "$totalCount"] }, - ], - }, - categories: "$categories", - }, - }, - }, - }, - { - $project: { - _id: 0, - subject: "$_id.subject", - courseNumber: "$_id.courseNumber", - metrics: 1, - }, - }, - ]); - - return ( - result[0] || { - subject, - courseNumber, - semester: null, - year: null, - classNumber: null, - metrics: [], - } - ); -}; diff --git a/apps/backend/src/modules/rating/resolver.ts b/apps/backend/src/modules/rating/resolver.ts index 36e9b4e16..4b0fdd48a 100644 --- a/apps/backend/src/modules/rating/resolver.ts +++ b/apps/backend/src/modules/rating/resolver.ts @@ -1,8 +1,8 @@ import { GraphQLError } from "graphql"; import { - createRatings, - deleteRatings, + createRating, + deleteRating, getClassAggregatedRatings, getSemestersWithRatings, getUserClassRatings, @@ -121,20 +121,21 @@ const resolvers: RatingModule.Resolvers = { }, }, Mutation: { - createRatings: async ( + createRating: async ( _, - { year, semester, subject, courseNumber, classNumber, metrics }, + { year, semester, subject, courseNumber, classNumber, metricName, value }, context ) => { try { - return await createRatings( + return await createRating( context, Number(year), semester, subject, courseNumber, classNumber, - metrics + metricName, + value ); } catch (error: unknown) { // Re-throw GraphQLErrors as is @@ -153,9 +154,21 @@ const resolvers: RatingModule.Resolvers = { } }, - deleteRatings: async (_, { subject, courseNumber }, context) => { + deleteRating: async ( + _, + { year, semester, subject, courseNumber, classNumber, metricName }, + context + ) => { try { - return await deleteRatings(context, subject, courseNumber); + return await deleteRating( + context, + Number(year), + semester, + subject, + courseNumber, + classNumber, + metricName + ); } catch (error: unknown) { // Re-throw GraphQLErrors as is if (error instanceof GraphQLError) { diff --git a/apps/backend/src/modules/semantic-search/client.ts b/apps/backend/src/modules/semantic-search/client.ts new file mode 100644 index 000000000..e3a5f5ab6 --- /dev/null +++ b/apps/backend/src/modules/semantic-search/client.ts @@ -0,0 +1,67 @@ +import { config } from "../../config"; + +interface SemanticSearchResult { + subject: string; + courseNumber: string; + title: string; + description: string; + score: number; + text: string; +} + +interface SemanticSearchResponse { + query: string; + threshold: number; + count: number; + year: number; + semester: string; + allowed_subjects: string[] | null; + last_refreshed: string; + results: SemanticSearchResult[]; +} + +export async function searchSemantic( + query: string, + year: number, + semester: string, + allowedSubjects?: string[], + threshold: number = 0.3 +): Promise { + const params = new URLSearchParams({ + query, + threshold: String(threshold), + year: String(year), + semester, + }); + + if (allowedSubjects && allowedSubjects.length > 0) { + allowedSubjects.forEach((subject) => { + params.append("allowed_subjects", subject); + }); + } + + const url = `${config.semanticSearch.url}/search?${params}`; + + try { + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Semantic search failed: ${response.statusText}`); + } + + return (await response.json()) as SemanticSearchResponse; + } catch (error) { + console.error("Semantic search error:", error); + // Return empty results on error, gracefully falling back + return { + query, + threshold, + count: 0, + year, + semester, + allowed_subjects: allowedSubjects || null, + last_refreshed: new Date().toISOString(), + results: [], + }; + } +} diff --git a/apps/backend/src/modules/semantic-search/controller.ts b/apps/backend/src/modules/semantic-search/controller.ts new file mode 100644 index 000000000..9d9a16048 --- /dev/null +++ b/apps/backend/src/modules/semantic-search/controller.ts @@ -0,0 +1,50 @@ +import { Request, Response } from "express"; + +import { searchSemantic } from "./client"; + +/** + * Lightweight semantic search endpoint that only returns course identifiers + * Frontend will use these to filter the already-loaded catalog + */ +export async function searchCourses(req: Request, res: Response) { + const { query, year, semester, threshold } = req.query; + + if (!query || typeof query !== "string") { + return res.status(400).json({ error: "query parameter is required" }); + } + + const yearNum = year ? parseInt(year as string, 10) : undefined; + const semesterStr = semester as string | undefined; + const thresholdNum = threshold ? parseFloat(threshold as string) : 0.3; + + try { + const results = await searchSemantic( + query, + yearNum!, + semesterStr!, + undefined, + thresholdNum + ); + + // Return lightweight response: only subject + courseNumber + score + const courseIds = results.results.map((r) => ({ + subject: r.subject, + courseNumber: r.courseNumber, + score: r.score, + })); + + return res.json({ + query, + threshold: thresholdNum, + results: courseIds, + count: courseIds.length, + }); + } catch (error) { + console.error("Semantic search error:", error); + return res.status(500).json({ + error: "Semantic search failed", + results: [], + count: 0, + }); + } +} diff --git a/apps/backend/src/modules/semantic-search/routes.ts b/apps/backend/src/modules/semantic-search/routes.ts new file mode 100644 index 000000000..198ae23f2 --- /dev/null +++ b/apps/backend/src/modules/semantic-search/routes.ts @@ -0,0 +1,113 @@ +import { type Response, Router } from "express"; +import type { ParsedQs } from "qs"; +import { RequestInit, fetch } from "undici"; + +import { config } from "../../config"; +import { searchCourses } from "./controller"; + +const router = Router(); +const baseUrl = config.semanticSearch.url.replace(/\/$/, ""); + +type QueryValue = string | ParsedQs | Array | undefined; + +const asString = (value: QueryValue): string | undefined => { + if (!value) return undefined; + if (typeof value === "string") return value; + if (Array.isArray(value)) { + for (const entry of value) { + const found = asString(entry as QueryValue); + if (found) return found; + } + } + return undefined; +}; + +const toStringList = (value: QueryValue): string[] => { + if (!value) return []; + if (Array.isArray(value)) { + const items: string[] = []; + for (const entry of value) { + items.push(...toStringList(entry as QueryValue)); + } + return items; + } + return typeof value === "string" && value.length > 0 ? [value] : []; +}; + +async function forward( + target: string, + init: RequestInit, + res: Response +): Promise { + try { + const response = await fetch(target, init); + const contentType = response.headers.get("content-type") ?? ""; + const raw = await response.text(); + + if (contentType.includes("application/json")) { + const payload = raw ? JSON.parse(raw) : {}; + res.status(response.status).json(payload); + } else { + res.status(response.status).send(raw); + } + } catch (error) { + console.error("Semantic search proxy error:", error); + res.status(502).json({ + error: "Unable to reach semantic search service", + details: String(error), + }); + } +} + +router.get("/health", async (_req, res) => { + await forward(`${baseUrl}/health`, { method: "GET" }, res); +}); + +router.post("/refresh", async (req, res) => { + const body = req.body ?? {}; + await forward( + `${baseUrl}/refresh`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + res + ); +}); + +// Lightweight endpoint: returns only course identifiers for frontend filtering +router.get("/courses", searchCourses); + +// Full proxy endpoint (kept for backwards compatibility) +router.get("/search", async (req, res) => { + const query = asString(req.query.query); + if (!query || !query.trim()) { + res.status(400).json({ error: "query parameter is required" }); + return; + } + + const params = new URLSearchParams({ query }); + + const topK = asString(req.query.top_k); + if (topK) params.set("top_k", topK); + + const year = asString(req.query.year); + if (year) params.set("year", year); + + const semester = asString(req.query.semester); + if (semester) params.set("semester", semester); + + const allowedSubjects = toStringList(req.query.allowed_subjects); + allowedSubjects.forEach((subject) => + params.append("allowed_subjects", subject) + ); + + await forward( + `${baseUrl}/search?${params.toString()}`, + { method: "GET" }, + res + ); +}); + +export default router; diff --git a/apps/backend/src/utils/graphql.ts b/apps/backend/src/utils/graphql.ts index 367c92011..e2ee7623d 100644 --- a/apps/backend/src/utils/graphql.ts +++ b/apps/backend/src/utils/graphql.ts @@ -1,4 +1,4 @@ -import { FragmentDefinitionNode, SelectionNode } from "graphql"; +import { SelectionNode } from "graphql"; // Recursively retrieve all fields from a GraphQL query export const getFields = (nodes: readonly SelectionNode[]): string[] => { @@ -18,38 +18,3 @@ export const getFields = (nodes: readonly SelectionNode[]): string[] => { }; // TODO: Middleware to detect expensive queries - -export const hasFieldPath = ( - nodes: readonly SelectionNode[], - fragments: Record, - path: string[] -): boolean => { - if (path.length === 0) return true; - - for (const node of nodes) { - if (node.kind === "Field") { - if (node.name.value !== path[0]) continue; - - if (path.length === 1) return true; - if ( - node.selectionSet && - hasFieldPath(node.selectionSet.selections, fragments, path.slice(1)) - ) { - return true; - } - } else if (node.kind === "InlineFragment") { - if (hasFieldPath(node.selectionSet.selections, fragments, path)) - return true; - } else if (node.kind === "FragmentSpread") { - const fragment = fragments[node.name.value]; - if ( - fragment && - hasFieldPath(fragment.selectionSet.selections, fragments, path) - ) { - return true; - } - } - } - - return false; -}; diff --git a/apps/backend/src/utils/subject.ts b/apps/backend/src/utils/subject.ts deleted file mode 100644 index 60bd73daa..000000000 --- a/apps/backend/src/utils/subject.ts +++ /dev/null @@ -1,17 +0,0 @@ -// Normalize subjects coming from user input so they match DB storage. -// Commas/whitespace are stripped (e.g., "A,RESEC" -> "ARESEC") while other -// punctuation like hyphens/slashes/& is preserved to avoid breaking legit -// subject codes (e.g., "HIN-URD", "MALAY/I", "L&S"). -export const normalizeSubject = (subject: string) => - subject.replace(/[,\s]/g, "").toUpperCase(); - -// Build a subject query that matches both the raw subject and its normalized -// variant. This lets us query collections where some records include commas -// (e.g., "A,RESEC") and others have them stripped (e.g., "ARESEC"). -export const buildSubjectQuery = (subject: string) => { - const normalized = normalizeSubject(subject); - const variants = Array.from( - new Set([subject, subject.toUpperCase(), normalized]) - ); - return { $in: variants }; -}; diff --git a/apps/datapuller/src/lib/courses.ts b/apps/datapuller/src/lib/courses.ts index 792421d11..5911271d0 100644 --- a/apps/datapuller/src/lib/courses.ts +++ b/apps/datapuller/src/lib/courses.ts @@ -8,8 +8,6 @@ import { fetchPaginatedData } from "./api/sis-api"; // Include relevant fields missing from the automically generated type type CombinedCourse = Course & { gradeReplacement?: ICourseItem["gradeReplacement"]; - departmentNicknames?: string; - subjectName?: string; }; const filterCourse = (input: CombinedCourse): boolean => { @@ -40,8 +38,6 @@ const formatCourse = (input: CombinedCourse) => { const output: ICourseItem = { courseId: courseId!, subject: subject!, - subjectName: input.subjectArea?.description, - departmentNicknames: input.departmentNicknames, number: number!, title: input.title, description: input.description, @@ -55,7 +51,6 @@ const formatCourse = (input: CombinedCourse) => { finalExam: input.finalExam?.code, academicGroup: input.academicGroup?.code, academicOrganization: input.academicOrganization?.code, - academicOrganizationName: input.academicOrganization?.description, instructorAddConsentRequired: input.instructorAddConsentRequired, instructorDropConsentRequired: input.instructorDropConsentRequired, allowMultipleEnrollments: input.allowMultipleEnrollments, @@ -122,7 +117,9 @@ const formatCourse = (input: CombinedCourse) => { discrete: input.credit?.value?.discrete?.units, fixed: input.credit?.value?.fixed?.units, range: { + // minUnits: input.credit?.value?.range?.minUnits, minUnits: input.credit?.value?.variable?.minUnits, + // maxUnits: input.credit?.value?.range?.maxUnits, maxUnits: input.credit?.value?.variable?.maxUnits, }, }, diff --git a/apps/datapuller/src/lib/enrollment.ts b/apps/datapuller/src/lib/enrollment.ts index e6380a008..95aad282d 100644 --- a/apps/datapuller/src/lib/enrollment.ts +++ b/apps/datapuller/src/lib/enrollment.ts @@ -9,16 +9,7 @@ import { filterSection } from "./sections"; // default enrollments datapuller scheduled interval (15 minutes) in seconds export const GRANULARITY = 15 * 60; -type RequirementGroupStats = { - present: number; - missing: number; -}; - -const formatEnrollmentSingular = ( - input: ClassSection, - time: Date, - requirementGroupStats?: RequirementGroupStats -) => { +const formatEnrollmentSingular = (input: ClassSection, time: Date) => { const termId = input.class?.session?.term?.id; const year = input.class?.session?.term?.name?.split(" ")[0]; const semester = input.class?.session?.term?.name?.split(" ")[1]; @@ -46,15 +37,6 @@ const formatEnrollmentSingular = ( if (missingField) throw new Error(`Missing essential section field: ${missingField[0]}`); - const seatReservations = input.enrollmentStatus?.seatReservations ?? []; - seatReservations.forEach((reservation) => { - if (reservation.requirementGroup?.description) { - requirementGroupStats && (requirementGroupStats.present += 1); - } else { - requirementGroupStats && (requirementGroupStats.missing += 1); - } - }); - const output: IEnrollmentSingularItem = { termId: termId!, year: parseInt(year!), @@ -81,20 +63,19 @@ const formatEnrollmentSingular = ( input.enrollmentStatus?.instructorAddConsentRequired, instructorDropConsentRequired: input.enrollmentStatus?.instructorDropConsentRequired, - seatReservationCount: seatReservations.map((reservation) => ({ - number: reservation.number ?? 0, - maxEnroll: reservation.maxEnroll ?? 0, - enrolledCount: reservation.enrolledCount ?? 0, - })), + seatReservationCount: input.enrollmentStatus?.seatReservations?.map( + (reservation) => ({ + number: reservation.number ?? 0, + maxEnroll: reservation.maxEnroll ?? 0, + enrolledCount: reservation.enrolledCount ?? 0, + }) + ), }, - seatReservationTypes: seatReservations - .filter((reservation) => reservation.requirementGroup?.description) + seatReservationTypes: input.enrollmentStatus?.seatReservations + ?.filter((reservation) => reservation.requirementGroup?.description) .map((reservation) => ({ number: reservation.number ?? 0, - requirementGroup: { - code: reservation.requirementGroup?.code ?? "", - description: reservation.requirementGroup?.description!, - }, + requirementGroup: reservation.requirementGroup?.description!, fromDate: reservation.fromDate ?? "", })), }; @@ -106,8 +87,7 @@ export const getEnrollmentSingulars = async ( logger: Logger, id: string, key: string, - termIds?: string[], - requirementGroupStats?: RequirementGroupStats + termIds?: string[] ) => { const classesAPI = new ClassesAPI(); @@ -122,8 +102,7 @@ export const getEnrollmentSingulars = async ( }, (data) => data.apiResponse?.response.classSections || [], filterSection, - (input) => - formatEnrollmentSingular(input, new Date(), requirementGroupStats) + (input) => formatEnrollmentSingular(input, new Date()) ); return sections; diff --git a/apps/datapuller/src/main.ts b/apps/datapuller/src/main.ts index 360b84c7f..c5cfe9e29 100644 --- a/apps/datapuller/src/main.ts +++ b/apps/datapuller/src/main.ts @@ -3,9 +3,9 @@ import { parseArgs } from "node:util"; import classesPuller from "./pullers/classes"; import coursesPuller from "./pullers/courses"; import enrollmentHistoriesPuller from "./pullers/enrollment"; -import enrollmentTimeframePuller from "./pullers/enrollment-timeframe"; import gradeDistributionsPuller from "./pullers/grade-distributions"; import sectionsPuller from "./pullers/sections"; +import semanticSearchPuller from "./pullers/semantic-search"; import termsPuller from "./pullers/terms"; import setup from "./shared"; import { Config } from "./shared/config"; @@ -27,9 +27,9 @@ const pullerMap: { "grades-recent": gradeDistributionsPuller.recentPastTerms, "grades-last-five-years": gradeDistributionsPuller.lastFiveYearsTerms, enrollments: enrollmentHistoriesPuller.updateEnrollmentHistories, - "enrollment-timeframe": enrollmentTimeframePuller.syncEnrollmentTimeframe, "terms-all": termsPuller.allTerms, "terms-nearby": termsPuller.nearbyTerms, + "semantic-search-refresh": semanticSearchPuller.refreshSemanticSearch, } as const; const runPuller = async () => { diff --git a/apps/datapuller/src/pullers/classes.ts b/apps/datapuller/src/pullers/classes.ts index 6ab8afd54..f7619a380 100644 --- a/apps/datapuller/src/pullers/classes.ts +++ b/apps/datapuller/src/pullers/classes.ts @@ -155,13 +155,6 @@ const updateClasses = async (config: Config, termSelector: TermSelector) => { }); const termsWithCatalogData = distinctTermNames.map((name) => ({ name })); - // Sort by year descending (latest first) - termsWithCatalogData.sort((a, b) => { - const yearA = parseInt(a.name.split(" ")[0], 10); - const yearB = parseInt(b.name.split(" ")[0], 10); - return yearB - yearA; - }); - // Process sequentially to avoid overwhelming the server await warmCatalogCacheForTerms(config, termsWithCatalogData); }; diff --git a/apps/datapuller/src/pullers/enrollment-calendar-parser.ts b/apps/datapuller/src/pullers/enrollment-calendar-parser.ts deleted file mode 100644 index 627995e28..000000000 --- a/apps/datapuller/src/pullers/enrollment-calendar-parser.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { ITermItem } from "@repo/common"; - -export type Semester = "Spring" | "Fall" | "Summer"; -export type Group = - | "continuing" - | "new_transfer" - | "new_freshman" - | "new_graduate" - | "new_student" - | "all"; -export type EventType = "start" | "end"; - -export interface ParsedEnrollmentEvent { - termCode: string; // "SP26", "FA25" - derived - semester: Semester; - year: number; // 4-digit: 2026 - phase: 1 | 2 | null; // null for adjustment - isAdjustment: boolean; - group: Group; - eventType: EventType; -} - -const SEMESTER_PREFIX_MAP: Record = { - SP: "Spring", - FA: "Fall", - SU: "Summer", -}; - -const SEMESTER_FULL_MAP: Record = { - spring: "Spring", - fall: "Fall", - summer: "Summer", -}; - -/** - * Extract semester and year from event summary prefix (e.g., "SP26", "FA25") - */ -function extractFromPrefix(summary: string): { - semester: Semester; - year: number; -} | null { - const match = summary.match(/^(SP|FA|SU)(\d{2})\s/i); - if (!match) return null; - - const prefix = match[1].toUpperCase(); - const yearShort = parseInt(match[2], 10); - const year = yearShort >= 50 ? 1900 + yearShort : 2000 + yearShort; - - const semester = SEMESTER_PREFIX_MAP[prefix]; - if (!semester) return null; - - return { semester, year }; -} - -/** - * Extract semester from full word format (e.g., "Spring - ", "Fall 2023 - ") - */ -function extractFromFullWord(summary: string): { - semester: Semester; - year: number | null; -} | null { - const match = summary.match(/^(Spring|Fall|Summer)(?:\s+(\d{4}))?\s*-/i); - if (!match) return null; - - const semesterWord = match[1].toLowerCase(); - const semester = SEMESTER_FULL_MAP[semesterWord]; - if (!semester) return null; - - const year = match[2] ? parseInt(match[2], 10) : null; - return { semester, year }; -} - -/** - * Infer year from terms collection by finding which term's enrollment period - * contains the event date. - */ -function inferYearFromTerms( - semester: Semester, - eventDate: Date, - terms: ITermItem[] -): number | null { - // Find UGRD terms that match the semester - const matchingTerms = terms.filter( - (t) => - t.name.toLowerCase().includes(semester.toLowerCase()) && - t.academicCareerCode === "UGRD" - ); - - for (const term of matchingTerms) { - const session = term.sessions?.[0]; - if (session?.enrollBeginDate) { - const enrollStart = new Date(session.enrollBeginDate); - // Use term end date as fallback if enrollEndDate not available - const enrollEnd = session.enrollEndDate - ? new Date(session.enrollEndDate) - : new Date(term.endDate); - - // Add buffer: enrollment events can happen slightly before/after official dates - const bufferDays = 14; - const bufferedStart = new Date(enrollStart); - bufferedStart.setDate(bufferedStart.getDate() - bufferDays); - const bufferedEnd = new Date(enrollEnd); - bufferedEnd.setDate(bufferedEnd.getDate() + bufferDays); - - if (eventDate >= bufferedStart && eventDate <= bufferedEnd) { - return parseInt(term.academicYear, 10); - } - } - } - - return null; -} - -/** - * Extract phase number (1 or 2) from summary - */ -function extractPhase(summary: string): 1 | 2 | null { - // Match "Phase 1", "Phase I", "Phase 2", "Phase II" - const match = summary.match(/Phase\s*([12I]+)/i); - if (!match) return null; - - const phaseStr = match[1].toUpperCase(); - if (phaseStr === "1" || phaseStr === "I") return 1; - if (phaseStr === "2" || phaseStr === "II") return 2; - - return null; -} - -/** - * Check if event is an adjustment period event - */ -function isAdjustmentEvent(summary: string): boolean { - return /adjustment\s*period/i.test(summary); -} - -/** - * Extract student group from summary - */ -function extractGroup(summary: string): Group { - const lowerSummary = summary.toLowerCase(); - - // Order matters - more specific patterns first - if ( - /new\s+(undergraduate\s+)?transfer/.test(lowerSummary) || - /new\s+transfer\s+undergrad/.test(lowerSummary) - ) { - return "new_transfer"; - } - - if ( - /new\s+(undergraduate\s+)?(freshman|first[\s-]*year)/.test(lowerSummary) || - /new\s+freshmen/.test(lowerSummary) - ) { - return "new_freshman"; - } - - if (/new\s+graduate/.test(lowerSummary)) { - return "new_graduate"; - } - - // Generic "new student" (used for shared end dates) - if ( - /new\s+student/.test(lowerSummary) || - /new\s+undergraduate\s+student/.test(lowerSummary) - ) { - return "new_student"; - } - - if (/continuing/.test(lowerSummary)) { - return "continuing"; - } - - // Default for adjustment periods or unspecified - return "all"; -} - -/** - * Extract event type (start or end) - */ -function extractEventType(summary: string): EventType | null { - const lowerSummary = summary.toLowerCase(); - - if (/\b(begin|start)/.test(lowerSummary)) { - return "start"; - } - - if (/\bend/.test(lowerSummary)) { - return "end"; - } - - return null; -} - -/** - * Generate term code from semester and year (e.g., "SP26", "FA25") - */ -function generateTermCode(semester: Semester, year: number): string { - const prefix = - semester === "Spring" ? "SP" : semester === "Fall" ? "FA" : "SU"; - const yearShort = year % 100; - return `${prefix}${yearShort.toString().padStart(2, "0")}`; -} - -/** - * Check if the summary matches enrollment phase patterns - */ -function isEnrollmentPhaseEvent(summary: string): boolean { - const lowerSummary = summary.toLowerCase(); - return ( - /phase\s*[12i]/i.test(summary) || - /adjustment\s*period/i.test(summary) || - (/enrollment\s*(begin|end|start)/i.test(summary) && - (lowerSummary.includes("continuing") || - lowerSummary.includes("new") || - lowerSummary.includes("phase"))) - ); -} - -/** - * Main parser function - parses enrollment event summary into structured data. - * Returns null if the event doesn't match enrollment phase patterns. - */ -export function parseEnrollmentEvent( - summary: string, - eventDate: Date, - terms: ITermItem[] -): ParsedEnrollmentEvent | null { - // First check if this looks like an enrollment phase event - if (!isEnrollmentPhaseEvent(summary)) { - return null; - } - - // Extract semester and year - let semester: Semester | null = null; - let year: number | null = null; - - // Try prefix format first (SP26, FA25) - const prefixResult = extractFromPrefix(summary); - if (prefixResult) { - semester = prefixResult.semester; - year = prefixResult.year; - } else { - // Try full word format (Spring - , Fall 2023 - ) - const fullWordResult = extractFromFullWord(summary); - if (fullWordResult) { - semester = fullWordResult.semester; - year = fullWordResult.year; - } - } - - // If we don't have semester, we can't parse this event - if (!semester) { - return null; - } - - // If year is missing, infer from terms - if (year === null) { - year = inferYearFromTerms(semester, eventDate, terms); - if (year === null) { - return null; // Can't determine year - } - } - - // Extract phase and adjustment status - const isAdjustment = isAdjustmentEvent(summary); - const phase = isAdjustment ? null : extractPhase(summary); - - // Must be either a phase event or adjustment event - if (!isAdjustment && phase === null) { - return null; - } - - // Extract event type - const eventType = extractEventType(summary); - if (!eventType) { - return null; - } - - // Extract group - const group = extractGroup(summary); - - // Generate term code - const termCode = generateTermCode(semester, year); - - return { - termCode, - semester, - year, - phase, - isAdjustment, - group, - eventType, - }; -} diff --git a/apps/datapuller/src/pullers/enrollment-timeframe.ts b/apps/datapuller/src/pullers/enrollment-timeframe.ts deleted file mode 100644 index c3ac821b2..000000000 --- a/apps/datapuller/src/pullers/enrollment-timeframe.ts +++ /dev/null @@ -1,528 +0,0 @@ -import { EnrollmentTimeframeModel, ITermItem, TermModel } from "@repo/common"; - -import { Config } from "../shared/config"; -import { - type Group, - type ParsedEnrollmentEvent, - type Semester, - parseEnrollmentEvent, -} from "./enrollment-calendar-parser"; - -// ============================================================================= -// iCal Fetching & Parsing -// ============================================================================= - -const ENROLLMENT_CALENDAR_URL = - "https://calendar.google.com/calendar/ical/c_lublpqqigfijlbc1l4rudcpi5s%40group.calendar.google.com/public/basic.ics"; - -type CalendarEvent = { - uid: string; - summary: string; - start: Date | null; -}; - -const TIMEZONE_OFFSETS: Record = { - "America/Los_Angeles": -8, - "America/New_York": -5, - "America/Chicago": -6, - "America/Denver": -7, - "America/Phoenix": -7, - "US/Pacific": -8, - "US/Eastern": -5, - "US/Central": -6, - "US/Mountain": -7, - UTC: 0, - GMT: 0, -}; - -function getTimezoneOffset(tzid: string | undefined, date: Date): number { - if (!tzid) return 0; - - const baseOffset = TIMEZONE_OFFSETS[tzid]; - if (baseOffset === undefined) { - console.warn(`Unknown timezone: ${tzid}, treating as UTC`); - return 0; - } - - if (tzid.includes("America/") || tzid.startsWith("US/")) { - const month = date.getUTCMonth(); - const day = date.getUTCDate(); - - const marchSecondSunday = - 14 - new Date(date.getUTCFullYear(), 2, 1).getDay(); - const novFirstSunday = 7 - new Date(date.getUTCFullYear(), 10, 1).getDay(); - - const isDST = - (month > 2 && month < 10) || - (month === 2 && day >= marchSecondSunday) || - (month === 10 && day < novFirstSunday); - - return isDST ? baseOffset + 1 : baseOffset; - } - - return baseOffset; -} - -async function fetchICal(): Promise { - const response = await fetch(ENROLLMENT_CALENDAR_URL); - if (!response.ok) { - throw new Error( - `Could not fetch enrollment calendar (${response.status} ${response.statusText})` - ); - } - return await response.text(); -} - -function unfoldLines(raw: string): string[] { - const lines = raw.split(/\r?\n/); - const unfolded: string[] = []; - - for (const line of lines) { - if (line.length === 0) continue; - if (line.startsWith(" ") || line.startsWith("\t")) { - if (unfolded.length > 0) { - unfolded[unfolded.length - 1] += line.slice(1); - } - } else { - unfolded.push(line); - } - } - - return unfolded; -} - -function parseICalDate(value: string | undefined, tzid?: string): Date | null { - if (!value) return null; - const s = value.trim(); - - if (/^\d{8}$/.test(s)) { - const year = Number(s.slice(0, 4)); - const month = Number(s.slice(4, 6)) - 1; - const day = Number(s.slice(6, 8)); - return new Date(Date.UTC(year, month, day)); - } - - const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/.exec( - s.toUpperCase() - ); - if (match) { - const [, year, month, day, hour, minute, second, isUTC] = match; - - if (isUTC) { - return new Date( - Date.UTC( - Number(year), - Number(month) - 1, - Number(day), - Number(hour), - Number(minute), - Number(second) - ) - ); - } - - const localDate = new Date( - Date.UTC( - Number(year), - Number(month) - 1, - Number(day), - Number(hour), - Number(minute), - Number(second) - ) - ); - - if (tzid) { - const offsetHours = getTimezoneOffset(tzid, localDate); - localDate.setUTCHours(localDate.getUTCHours() - offsetHours); - return localDate; - } - - return new Date( - Number(year), - Number(month) - 1, - Number(day), - Number(hour), - Number(minute), - Number(second) - ); - } - - return null; -} - -type ParsedProperty = { - value: string; - params: Record; -}; - -function parsePropertyLine(line: string): { - name: string; - prop: ParsedProperty; -} { - const colonIndex = line.indexOf(":"); - if (colonIndex === -1) { - return { name: "", prop: { value: "", params: {} } }; - } - - const left = line.slice(0, colonIndex); - const value = line.slice(colonIndex + 1); - - const parts = left.split(";"); - const rawName = parts[0].toUpperCase(); - const params: Record = {}; - - for (let i = 1; i < parts.length; i++) { - const eqIndex = parts[i].indexOf("="); - if (eqIndex !== -1) { - const paramName = parts[i].slice(0, eqIndex).toUpperCase(); - let paramValue = parts[i].slice(eqIndex + 1); - if (paramValue.startsWith('"') && paramValue.endsWith('"')) { - paramValue = paramValue.slice(1, -1); - } - params[paramName] = paramValue; - } - } - - return { name: rawName, prop: { value, params } }; -} - -function parseCalendar(ics: string): CalendarEvent[] { - const lines = unfoldLines(ics); - const events: CalendarEvent[] = []; - - let inEvent = false; - let current: Record = {}; - - for (const line of lines) { - if (line === "BEGIN:VEVENT") { - inEvent = true; - current = {}; - continue; - } - - if (line === "END:VEVENT") { - if (inEvent) { - const uid = current["UID"]?.value; - const summary = current["SUMMARY"]?.value; - if (uid && summary) { - const dtstart = current["DTSTART"]; - const startTzid = dtstart?.params["TZID"]; - const start = parseICalDate(dtstart?.value, startTzid); - - events.push({ uid, summary, start }); - } - } - - inEvent = false; - current = {}; - continue; - } - - if (!inEvent) continue; - - const { name, prop } = parsePropertyLine(line); - if (name) { - current[name] = prop; - } - } - - return events; -} - -// ============================================================================= -// Timeframe Building -// ============================================================================= - -interface ParsedEventInput { - uid: string; - summary: string; - startDate: Date; - parsedEvent: ParsedEnrollmentEvent; -} - -interface TimeframeKey { - termCode: string; - phase: number | null; - isAdjustment: boolean; - group: Group; -} - -function makeKey(k: TimeframeKey): string { - return `${k.termCode}|${k.phase ?? "adj"}|${k.isAdjustment}|${k.group}`; -} - -/** - * Groups that should inherit from "new_student" end events - */ -const NEW_STUDENT_GROUPS: Group[] = [ - "new_transfer", - "new_freshman", - "new_graduate", -]; - -/** - * Build timeframes from parsed enrollment events by pairing start/end events. - */ -function buildTimeframes( - parsedEvents: ParsedEventInput[], - termLookup: Map, - log: Config["log"] -) { - // Group events by (termCode, phase, isAdjustment, group, eventType) - const startEvents = new Map(); - const endEvents = new Map(); - - for (const event of parsedEvents) { - const { parsedEvent } = event; - const key: TimeframeKey = { - termCode: parsedEvent.termCode, - phase: parsedEvent.phase, - isAdjustment: parsedEvent.isAdjustment, - group: parsedEvent.group, - }; - const keyStr = makeKey(key); - - if (parsedEvent.eventType === "start") { - const existing = startEvents.get(keyStr); - if (!existing || event.startDate < existing.startDate) { - startEvents.set(keyStr, event); - } - } else { - const existing = endEvents.get(keyStr); - if (!existing || event.startDate > existing.startDate) { - endEvents.set(keyStr, event); - } - } - } - - log.trace( - `Found ${startEvents.size} unique start events, ${endEvents.size} unique end events.` - ); - - // Build timeframes by matching start and end events - const timeframes: Array<{ - termId: string; - year: number; - semester: Semester; - phase: number | null; - isAdjustment: boolean; - group: Group; - startDate: Date; - endDate: Date | undefined; - startEventUid: string; - startEventSummary: string; - endEventUid: string | undefined; - endEventSummary: string | undefined; - }> = []; - - for (const [keyStr, startEvent] of startEvents) { - const { parsedEvent } = startEvent; - - const termInfo = termLookup.get(parsedEvent.termCode); - if (!termInfo) { - log.warn( - `No term found for termCode ${parsedEvent.termCode}, skipping timeframe.` - ); - continue; - } - - // Find matching end event - let endEvent = endEvents.get(keyStr); - - // If no direct end event and this is a new student group, try "new_student" end - if ( - !endEvent && - NEW_STUDENT_GROUPS.includes(parsedEvent.group) && - !parsedEvent.isAdjustment - ) { - const newStudentKey: TimeframeKey = { - termCode: parsedEvent.termCode, - phase: parsedEvent.phase, - isAdjustment: false, - group: "new_student", - }; - endEvent = endEvents.get(makeKey(newStudentKey)); - } - - // Also try "all" as a fallback for end events - if (!endEvent) { - const allKey: TimeframeKey = { - termCode: parsedEvent.termCode, - phase: parsedEvent.phase, - isAdjustment: parsedEvent.isAdjustment, - group: "all", - }; - endEvent = endEvents.get(makeKey(allKey)); - } - - timeframes.push({ - termId: termInfo.termId, - year: parsedEvent.year, - semester: parsedEvent.semester, - phase: parsedEvent.phase, - isAdjustment: parsedEvent.isAdjustment, - group: parsedEvent.group, - startDate: startEvent.startDate, - endDate: endEvent?.startDate, - startEventUid: startEvent.uid, - startEventSummary: startEvent.summary, - endEventUid: endEvent?.uid, - endEventSummary: endEvent?.summary, - }); - } - - return timeframes; -} - -// ============================================================================= -// Main Sync Function -// ============================================================================= - -/** - * Sync enrollment timeframes from the UC Berkeley enrollment calendar. - * Fetches the iCal feed, parses enrollment events, and upserts timeframes. - */ -const syncEnrollmentTimeframe = async (config: Config) => { - const { log } = config; - - log.trace( - `Enrollment timeframe collection: ${EnrollmentTimeframeModel.collection.collectionName}` - ); - - // Fetch and parse iCal - log.trace("Fetching enrollment calendar iCal feed..."); - const iCalContents = await fetchICal(); - const calendarEvents = parseCalendar(iCalContents); - - log.info(`Fetched ${calendarEvents.length} calendar events.`); - - if (calendarEvents.length === 0) { - log.warn("No events returned from the enrollment calendar feed."); - return; - } - - // Fetch terms for year inference and termId lookup - log.trace("Fetching terms..."); - const terms = (await TermModel.find({}).lean()) as ITermItem[]; - log.trace(`Loaded ${terms.length} terms.`); - - // Build termCode -> termId lookup - const termLookup = new Map(); - for (const term of terms) { - const nameParts = term.name.split(" "); - if (nameParts.length >= 2) { - const year = parseInt(nameParts[0], 10); - const semester = nameParts[1]; - if (!isNaN(year) && semester) { - const yearShort = year % 100; - const prefix = - semester === "Spring" ? "SP" : semester === "Fall" ? "FA" : "SU"; - const termCode = `${prefix}${yearShort.toString().padStart(2, "0")}`; - termLookup.set(termCode, { termId: term.id }); - } - } - } - - // Parse calendar events into enrollment events - const parsedEvents: ParsedEventInput[] = []; - for (const event of calendarEvents) { - if (!event.start) continue; - - const parsedEvent = parseEnrollmentEvent(event.summary, event.start, terms); - if (parsedEvent) { - parsedEvents.push({ - uid: event.uid, - summary: event.summary, - startDate: event.start, - parsedEvent, - }); - } - } - - log.info( - `Parsed ${parsedEvents.length} enrollment events from ${calendarEvents.length} calendar events.` - ); - - if (parsedEvents.length === 0) { - log.warn("No enrollment events parsed; nothing to aggregate."); - return; - } - - // Build timeframes from parsed events - const timeframes = buildTimeframes(parsedEvents, termLookup, log); - - log.info(`Built ${timeframes.length} enrollment timeframes.`); - - if (timeframes.length === 0) { - log.warn("No timeframes built; nothing to upsert."); - return; - } - - // Upsert timeframes - const now = new Date(); - const bulkOps = timeframes.map((tf) => ({ - updateOne: { - filter: { - termId: tf.termId, - phase: tf.phase, - isAdjustment: tf.isAdjustment, - group: tf.group, - }, - update: { - $set: { - termId: tf.termId, - year: tf.year, - semester: tf.semester, - phase: tf.phase, - isAdjustment: tf.isAdjustment, - group: tf.group, - startDate: tf.startDate, - endDate: tf.endDate, - startEventUid: tf.startEventUid, - startEventSummary: tf.startEventSummary, - endEventUid: tf.endEventUid, - endEventSummary: tf.endEventSummary, - lastSyncedAt: now, - }, - }, - upsert: true, - }, - })); - - log.trace("Upserting enrollment timeframes..."); - const bulkResult = await EnrollmentTimeframeModel.bulkWrite(bulkOps, { - ordered: false, - }); - - log.info( - `Upserted timeframes: ${bulkResult.upsertedCount} inserted, ${bulkResult.modifiedCount} updated, ${bulkResult.matchedCount} matched.` - ); - - // Remove stale timeframes - const validKeys = new Set( - timeframes.map( - (tf) => `${tf.termId}|${tf.phase}|${tf.isAdjustment}|${tf.group}` - ) - ); - - const allTimeframes = await EnrollmentTimeframeModel.find({}).lean(); - const staleIds = allTimeframes - .filter((tf) => { - const key = `${tf.termId}|${tf.phase}|${tf.isAdjustment}|${tf.group}`; - return !validKeys.has(key); - }) - .map((tf) => tf._id); - - if (staleIds.length > 0) { - log.trace("Removing stale timeframes..."); - const deleteResult = await EnrollmentTimeframeModel.deleteMany({ - _id: { $in: staleIds }, - }); - log.info(`Removed ${deleteResult.deletedCount} stale timeframes.`); - } -}; - -export default { - syncEnrollmentTimeframe, -}; diff --git a/apps/datapuller/src/pullers/enrollment.ts b/apps/datapuller/src/pullers/enrollment.ts index 6dba3e348..bda4e4c83 100644 --- a/apps/datapuller/src/pullers/enrollment.ts +++ b/apps/datapuller/src/pullers/enrollment.ts @@ -64,32 +64,6 @@ const enrollmentSingularsEqual = ( return true; }; -const seatReservationTypesEqual = ( - a: NonNullable, - b: NonNullable -) => { - if (a.length !== b.length) return false; - - const byNumber = (arr: typeof a) => - arr - .map((item) => ({ - number: item.number, - code: item.requirementGroup?.code ?? null, - description: item.requirementGroup?.description ?? null, - })) - .sort((x, y) => (x.number ?? 0) - (y.number ?? 0)); - - const aSorted = byNumber(a); - const bSorted = byNumber(b); - - return aSorted.every( - (item, idx) => - item.number === bSorted[idx].number && - item.code === bSorted[idx].code && - item.description === bSorted[idx].description - ); -}; - const updateEnrollmentHistories = async (config: Config) => { const { log, @@ -121,7 +95,6 @@ const updateEnrollmentHistories = async (config: Config) => { let totalEnrollmentSingulars = 0; let totalInserted = 0; let totalUpdated = 0; - const requirementGroupStats = { present: 0, missing: 0 }; for (let i = 0; i < terms.length; i += TERMS_PER_API_BATCH) { const termsBatch = terms.slice(i, i + TERMS_PER_API_BATCH); @@ -135,8 +108,7 @@ const updateEnrollmentHistories = async (config: Config) => { log, CLASS_APP_ID, CLASS_APP_KEY, - termsBatchIds, - requirementGroupStats + termsBatchIds ); log.info( @@ -272,34 +244,32 @@ const updateEnrollmentHistories = async (config: Config) => { } } - // Keep seatReservationTypes fresh if new data differs from stored + /* + Start Migration 11/18/2025: Fix missing seatReservationTypes + */ if ( existingDoc && - enrollmentSingular.seatReservationTypes && - enrollmentSingular.seatReservationTypes.length > 0 + (existingDoc.seatReservationTypes === undefined || + existingDoc.seatReservationTypes === null || + existingDoc.seatReservationTypes.length === 0) && + enrollmentSingular.seatReservationTypes !== undefined && + enrollmentSingular.seatReservationTypes !== null && + enrollmentSingular.seatReservationTypes.length !== 0 ) { - const existingTypes = existingDoc.seatReservationTypes ?? []; - const incomingTypes = enrollmentSingular.seatReservationTypes ?? []; - const hasUnknown = existingTypes.some( - (t) => - !t.requirementGroup?.description || - t.requirementGroup.description === "Unknown" - ); - - const needsUpdate = - hasUnknown || - existingTypes.length === 0 || - !seatReservationTypesEqual(existingTypes, incomingTypes); - - if (needsUpdate) { - bulkOps.push({ - updateOne: { - filter: { _id: existingDoc._id }, - update: { $set: { seatReservationTypes: incomingTypes } }, + bulkOps.push({ + updateOne: { + filter: { _id: existingDoc._id }, + update: { + $set: { + seatReservationTypes: enrollmentSingular.seatReservationTypes, + }, }, - }); - } + }, + }); } + /* + End Migration 11/18/2025 + */ } // Execute bulk operations for this batch @@ -311,10 +281,6 @@ const updateEnrollmentHistories = async (config: Config) => { } } - log.info( - `Seat reservation groups: ${requirementGroupStats.present.toLocaleString()} with descriptions, ${requirementGroupStats.missing.toLocaleString()} missing.` - ); - log.info( `Completed updating database with ${totalEnrollmentSingulars.toLocaleString()} enrollments: ${totalInserted.toLocaleString()} inserted, ${totalUpdated.toLocaleString()} updated.` ); diff --git a/apps/datapuller/src/pullers/grade-distributions.ts b/apps/datapuller/src/pullers/grade-distributions.ts index 6666b26ef..d71774fff 100644 --- a/apps/datapuller/src/pullers/grade-distributions.ts +++ b/apps/datapuller/src/pullers/grade-distributions.ts @@ -1,10 +1,4 @@ -import { - CourseModel, - GradeCounts, - GradeDistributionModel, - getAverageGrade, - getDistribution, -} from "@repo/common"; +import { GradeDistributionModel } from "@repo/common"; import { getGradeDistributionDataByTerms } from "../lib/grade-distributions"; import { Config } from "../shared/config"; @@ -16,108 +10,6 @@ import { const TERMS_PER_API_BATCH = 100; -interface AggregatedCourseGradeSummary extends GradeCounts { - _id: { - subject: string; - courseNumber: string; - }; -} - -const rebuildCourseGradeSummaries = async (log: Config["log"]) => { - log.info("Recomputing course grade summaries..."); - - await CourseModel.updateMany( - { - $or: [ - { allTimeAverageGrade: { $ne: null } }, - { allTimePassCount: { $ne: null } }, - { allTimeNoPassCount: { $ne: null } }, - ], - }, - { - $set: { - allTimeAverageGrade: null, - allTimePassCount: null, - allTimeNoPassCount: null, - }, - } - ); - - const aggregatedSummaries = - await GradeDistributionModel.aggregate([ - { - $group: { - _id: { subject: "$subject", courseNumber: "$courseNumber" }, - countAPlus: { $sum: "$countAPlus" }, - countA: { $sum: "$countA" }, - countAMinus: { $sum: "$countAMinus" }, - countBPlus: { $sum: "$countBPlus" }, - countB: { $sum: "$countB" }, - countBMinus: { $sum: "$countBMinus" }, - countCPlus: { $sum: "$countCPlus" }, - countC: { $sum: "$countC" }, - countCMinus: { $sum: "$countCMinus" }, - countDPlus: { $sum: "$countDPlus" }, - countD: { $sum: "$countD" }, - countDMinus: { $sum: "$countDMinus" }, - countF: { $sum: "$countF" }, - countNP: { $sum: "$countNP" }, - countP: { $sum: "$countP" }, - countU: { $sum: "$countU" }, - countS: { $sum: "$countS" }, - }, - }, - ]); - - if (aggregatedSummaries.length === 0) { - log.warn("No grade distributions found when rebuilding summaries."); - return; - } - - const operations = aggregatedSummaries - .map(({ _id, ...counts }) => { - const gradeCounts = counts as GradeCounts; - const distribution = getDistribution([gradeCounts]); - const averageGrade = getAverageGrade(distribution); - const passCount = gradeCounts.countP + gradeCounts.countS; - const noPassCount = gradeCounts.countNP + gradeCounts.countU; - const hasPnpData = passCount + noPassCount > 0; - - if (averageGrade === null && !hasPnpData) return null; - - return { - updateMany: { - filter: { subject: _id.subject, number: _id.courseNumber }, - update: { - $set: { - allTimeAverageGrade: averageGrade, - allTimePassCount: hasPnpData ? passCount : null, - allTimeNoPassCount: hasPnpData ? noPassCount : null, - }, - }, - }, - }; - }) - .filter((op): op is NonNullable => op !== null); - - if (operations.length === 0) { - log.warn("No course summaries to update."); - return; - } - - const BULK_BATCH_SIZE = 500; - let processed = 0; - for (let i = 0; i < operations.length; i += BULK_BATCH_SIZE) { - const batch = operations.slice(i, i + BULK_BATCH_SIZE); - await CourseModel.bulkWrite(batch, { ordered: false }); - processed += batch.length; - } - - log.info( - `Updated grade summaries for ${processed.toLocaleString()} course combinations.` - ); -}; - const updateGradeDistributions = async ( config: Config, termSelector: TermSelector @@ -192,8 +84,6 @@ const updateGradeDistributions = async ( log.info( `Completed updating database with ${totalGradeDistributions.toLocaleString()} grade distributions, inserted ${totalInserted.toLocaleString()} documents.` ); - - await rebuildCourseGradeSummaries(log); }; const recentPastTerms = async (config: Config) => { diff --git a/apps/datapuller/src/pullers/semantic-search.ts b/apps/datapuller/src/pullers/semantic-search.ts new file mode 100644 index 000000000..1a366bf09 --- /dev/null +++ b/apps/datapuller/src/pullers/semantic-search.ts @@ -0,0 +1,66 @@ +import { TermModel } from "@repo/common"; + +import { Config } from "../shared/config"; + +const refreshSemanticSearch = async (config: Config) => { + const { log, SEMANTIC_SEARCH_URL } = config; + + log.trace("Refreshing semantic search indices..."); + + // Find all active terms (terms that are currently open or will open soon) + const now = new Date(); + const activeTerms = await TermModel.find({ + endDate: { $gte: now }, + }) + .sort({ startDate: 1 }) + .limit(3) // Refresh current and next 2 terms + .lean(); + + if (activeTerms.length === 0) { + log.info("No active terms found to refresh."); + return; + } + + log.info(`Found ${activeTerms.length} active term(s) to refresh.`); + + for (const term of activeTerms) { + try { + const year = term.year; + const semester = term.semester; + + log.trace(`Refreshing index for ${year} ${semester}...`); + + const response = await fetch(`${SEMANTIC_SEARCH_URL}/refresh`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + year, + semester, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Failed to refresh ${year} ${semester}: ${response.status} ${errorText}` + ); + } + + const result = await response.json(); + log.info(`Refreshed ${year} ${semester}: ${result.size} courses indexed`); + } catch (error: any) { + log.error( + `Error refreshing ${term.year} ${term.semester}: ${error.message}` + ); + // Continue with other terms even if one fails + } + } + + log.trace("Semantic search refresh completed."); +}; + +export default { + refreshSemanticSearch, +}; diff --git a/apps/datapuller/src/shared/config.ts b/apps/datapuller/src/shared/config.ts index 1e574cb4d..35964724b 100644 --- a/apps/datapuller/src/shared/config.ts +++ b/apps/datapuller/src/shared/config.ts @@ -32,6 +32,7 @@ export interface Config { WORKGROUP: string; }; BACKEND_URL: string; + SEMANTIC_SEARCH_URL: string; } export function loadConfig(): Config { @@ -64,5 +65,6 @@ export function loadConfig(): Config { WORKGROUP: env("AWS_WORKGROUP"), }, BACKEND_URL: env("BACKEND_URL"), + SEMANTIC_SEARCH_URL: env("SEMANTIC_SEARCH_URL"), }; } diff --git a/apps/docs/src/core/datapuller/local-remote-development.md b/apps/docs/src/core/datapuller/local-remote-development.md index b4b916f97..4a6bc08fc 100644 --- a/apps/docs/src/core/datapuller/local-remote-development.md +++ b/apps/docs/src/core/datapuller/local-remote-development.md @@ -28,7 +28,6 @@ The valid pullers are: - `grades-recent` - `grades-last-five-years` - `enrollments` -- `enrollment-calendar` - `terms-all` - `terms-nearby` diff --git a/apps/frontend/package.json b/apps/frontend/package.json index f87a98a9d..555ece5ee 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -23,7 +23,6 @@ "graphql": "^16.11.0", "iconoir-react": "^7.11.0", "mapbox-gl": "^3.15.0", - "maplibre-gl": "^5.13.0", "moment": "^2.30.1", "radix-ui": "^1.4.3", "react": "^19.2.0", diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 941e39321..7e785e11d 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -12,7 +12,6 @@ import { ThemeProvider } from "@repo/theme"; import Layout from "@/components/Layout"; import SuspenseBoundary from "@/components/SuspenseBoundary"; -import { ToastContextProvider } from "@/components/Toast"; import UserProvider from "@/providers/UserProvider"; const Landing = lazy(() => import("@/app/Landing")); @@ -319,21 +318,15 @@ const router = createBrowserRouter([ path: "grades", }, { - element: ( - - - - ), + element: , path: "ratings", }, { path: "*", - loader: ({ - params: { year, semester, subject, courseNumber, number }, - }) => { - const basePath = `/catalog/${year}/${semester}/${subject}/${courseNumber}`; - return redirect(number ? `${basePath}/${number}` : basePath); - }, + loader: ({ params: { year, semester, subject, courseNumber } }) => + redirect( + `/catalog/${year}/${semester}/${subject}/${courseNumber}` + ), }, ], }, @@ -392,9 +385,7 @@ export default function App() { - - - + diff --git a/apps/frontend/src/_mixins.scss b/apps/frontend/src/_mixins.scss deleted file mode 100644 index 293703915..000000000 --- a/apps/frontend/src/_mixins.scss +++ /dev/null @@ -1,21 +0,0 @@ -/// Dark mode mixin that respects both user theme override and system preference. -/// Usage: -/// .element { -/// color: black; -/// @include dark-mode { -/// color: white; -/// } -/// } -@mixin dark-mode { - // When user explicitly sets dark mode via settings - body[data-theme="dark"] & { - @content; - } - - // When user has "System Default" and system prefers dark - @media (prefers-color-scheme: dark) { - body:not([data-theme]) & { - @content; - } - } -} diff --git a/apps/frontend/src/app/Catalog/Catalog.module.scss b/apps/frontend/src/app/Catalog/Catalog.module.scss index 94c5b605b..590e3f2ae 100644 --- a/apps/frontend/src/app/Catalog/Catalog.module.scss +++ b/apps/frontend/src/app/Catalog/Catalog.module.scss @@ -21,8 +21,8 @@ background-color: var(--foreground-color); .title { - font-size: var(--text-14); - font-weight: var(--font-medium); + font-size: 14px; + font-weight: 500; color: var(--heading-color); line-height: 1; } @@ -121,8 +121,8 @@ } &:hover { - background-color: var(--blue-hover); - border-color: var(--blue-hover); + background-color: var(--blue-600); + border-color: var(--blue-600); } svg { @@ -135,3 +135,20 @@ display: none; // Hide on desktop } } + +.loading { + display: flex; + flex-direction: column; + flex-grow: 1; + + .loadingHeader { + height: 238.5px; // edit to match class header height + background-color: var(--foreground-color); + border-bottom: 1px solid var(--border-color); + } + + .loadingBody { + flex-grow: 1; + background-color: var(--background-color); + } +} diff --git a/apps/frontend/src/app/Catalog/index.tsx b/apps/frontend/src/app/Catalog/index.tsx index 14c386ad1..6e182c4fc 100644 --- a/apps/frontend/src/app/Catalog/index.tsx +++ b/apps/frontend/src/app/Catalog/index.tsx @@ -1,33 +1,17 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { useMutation, useQuery } from "@apollo/client/react"; import classNames from "classnames"; import { NavArrowRight, Xmark } from "iconoir-react"; import { useLocation, useNavigate, useParams } from "react-router-dom"; -import { MetricName, REQUIRED_METRICS } from "@repo/shared"; -import { USER_REQUIRED_RATINGS_TO_UNLOCK } from "@repo/shared"; import { Flex, IconButton } from "@repo/theme"; import Class from "@/components/Class"; -import { - ErrorDialog, - SubmitRatingPopup, -} from "@/components/Class/Ratings/RatingDialog"; -import UserFeedbackModal from "@/components/Class/Ratings/UserFeedbackModal"; -import { MetricData } from "@/components/Class/Ratings/metricsUtil"; import ClassBrowser from "@/components/ClassBrowser"; -import { useToast } from "@/components/Toast"; import { useReadTerms } from "@/hooks/api"; -import { useGetClass } from "@/hooks/api/classes/useGetClass"; -import useUser from "@/hooks/useUser"; -import { - CreateRatingsDocument, - GetUserRatingsDocument, - Semester, -} from "@/lib/generated/graphql"; +import { useReadClass } from "@/hooks/api/classes/useReadClass"; +import { Semester } from "@/lib/generated/graphql"; import { RecentType, addRecent, getRecents } from "@/lib/recent"; -import { getRatingErrorMessage } from "@/utils/ratingErrorMessages"; import styles from "./Catalog.module.scss"; @@ -50,117 +34,11 @@ export default function Catalog() { const navigate = useNavigate(); const location = useLocation(); - const { showToast } = useToast(); const [catalogDrawerOpen, setCatalogDrawerOpen] = useState(false); const [showFloatingButton, setShowFloatingButton] = useState(false); - const [isUnlockModalOpen, setIsUnlockModalOpen] = useState(false); - const [unlockModalGoalCount, setUnlockModalGoalCount] = useState(0); - const [isUnlockThankYouOpen, setIsUnlockThankYouOpen] = useState(false); - const [errorMessage, setErrorMessage] = useState(""); - const [isErrorDialogOpen, setIsErrorDialogOpen] = useState(false); - - const { user } = useUser(); const { data: terms, loading: termsLoading } = useReadTerms(); - const [createUnlockRatings] = useMutation(CreateRatingsDocument); - - const { data: userRatingsData, loading: userRatingsLoading } = useQuery( - GetUserRatingsDocument, - { - skip: !user, - } - ); - - const userRatingsCount = useMemo( - () => userRatingsData?.userRatings?.classes?.length ?? 0, - [userRatingsData] - ); - - const userRatedClasses = useMemo(() => { - const ratedClasses = - userRatingsData?.userRatings?.classes?.map((cls) => ({ - subject: cls.subject, - courseNumber: cls.courseNumber, - })) ?? []; - - const seen = new Set(); - return ratedClasses.filter((cls) => { - const key = `${cls.subject}-${cls.courseNumber}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); - }, [userRatingsData]); - - const ratingsNeeded = Math.max( - 0, - USER_REQUIRED_RATINGS_TO_UNLOCK - userRatingsCount - ); - - const openUnlockModal = useCallback(() => { - if (!user) return; - const goalCount = - ratingsNeeded <= 0 ? USER_REQUIRED_RATINGS_TO_UNLOCK : ratingsNeeded; - setUnlockModalGoalCount(goalCount); - setIsUnlockModalOpen(true); - setIsUnlockThankYouOpen(false); - }, [ratingsNeeded, user]); - - const handleUnlockModalClose = useCallback(() => { - setIsUnlockModalOpen(false); - setUnlockModalGoalCount(0); - setIsUnlockThankYouOpen(false); - }, []); - - const METRIC_NAMES = Object.values(MetricName) as MetricName[]; - - const handleUnlockRatingSubmit = useCallback( - async ( - metricValues: MetricData, - termInfo: { semester: Semester; year: number }, - classInfo: { subject: string; courseNumber: string; classNumber: string } - ) => { - const populatedMetrics = METRIC_NAMES.filter( - (metric) => typeof metricValues[metric] === "number" - ); - if (populatedMetrics.length === 0) { - throw new Error(`No populated metrics`); - } - - const missingRequiredMetrics = REQUIRED_METRICS.filter( - (metric) => !populatedMetrics.includes(metric) - ); - if (missingRequiredMetrics.length > 0) { - throw new Error( - `Missing required metrics: ${missingRequiredMetrics.join(", ")}` - ); - } - - const metrics = populatedMetrics.map((metric) => ({ - metricName: metric, - value: metricValues[metric] as number, - })); - - await createUnlockRatings({ - variables: { - subject: classInfo.subject, - courseNumber: classInfo.courseNumber, - semester: termInfo.semester, - year: termInfo.year, - classNumber: classInfo.classNumber, - metrics, - }, - refetchQueries: [{ query: GetUserRatingsDocument }], - awaitRefetchQueries: true, - }); - }, - [METRIC_NAMES, createUnlockRatings] - ); - - const shouldShowUnlockModal = - !!user && - ((unlockModalGoalCount > 0 && isUnlockModalOpen) || isUnlockThankYouOpen); const semester = useMemo(() => { if (!providedSemester) return null; @@ -211,9 +89,9 @@ export default function Catalog() { [providedSubject] ); - const { data: _class } = useGetClass( + const { data: _class, loading: classLoading } = useReadClass( term?.year as number, - term?.semester as Semester, + term?.semester, subject as string, courseNumber as string, number as string, @@ -222,6 +100,9 @@ export default function Catalog() { } ); + // Course data is already included in _class via the backend resolver + const _course = _class?.course; + const handleSelect = useCallback( (subject: string, courseNumber: string, number: string) => { if (!term) return; @@ -236,28 +117,10 @@ export default function Catalog() { [navigate, location, term] ); - useEffect(() => { - if (!user || userRatingsLoading || !userRatingsData || ratingsNeeded <= 0) - return; - - showToast({ - title: "Unlock all features by reviewing classes you have taken", - action: { - label: "Start", - onClick: openUnlockModal, - }, - }); - }, [ - showToast, - openUnlockModal, - user, - userRatingsLoading, - userRatingsData, - ratingsNeeded, - ]); - + // Handle mouse movement for floating button on mobile useEffect(() => { const handleMouseMove = (e: MouseEvent) => { + // Only show on mobile if (window.innerWidth > 992) { setShowFloatingButton(false); return; @@ -273,9 +136,9 @@ export default function Catalog() { if (termsLoading) { return ( -
-
-
+
+
+
); } @@ -342,36 +205,15 @@ export default function Catalog() { - {_class ? : null} + {classLoading ? ( +
+
+
+
+ ) : _class && _course ? ( + {}} /> + ) : null} - - {shouldShowUnlockModal && ( - { - const message = getRatingErrorMessage(error); - setErrorMessage(message); - setIsErrorDialogOpen(true); - }} - /> - )} - setIsUnlockThankYouOpen(false)} - /> - setIsErrorDialogOpen(false)} - errorMessage={errorMessage} - />
); } diff --git a/apps/frontend/src/app/CuratedClasses/CuratedClasses.module.scss b/apps/frontend/src/app/CuratedClasses/CuratedClasses.module.scss index 17214e6b2..172448624 100644 --- a/apps/frontend/src/app/CuratedClasses/CuratedClasses.module.scss +++ b/apps/frontend/src/app/CuratedClasses/CuratedClasses.module.scss @@ -2,8 +2,8 @@ display: flex; gap: 12px; align-items: center; - font-size: var(--text-14); - font-weight: var(--font-medium); + font-size: 14px; + font-weight: 500; color: var(--orange-500); line-height: 1; flex-grow: 1; @@ -24,7 +24,7 @@ } .description { - font-size: var(--text-16); + font-size: 16px; line-height: 1.5; color: var(--paragraph-color); } diff --git a/apps/frontend/src/app/Discover/Discover.module.scss b/apps/frontend/src/app/Discover/Discover.module.scss index 5fa1c67ac..7926a2849 100644 --- a/apps/frontend/src/app/Discover/Discover.module.scss +++ b/apps/frontend/src/app/Discover/Discover.module.scss @@ -26,15 +26,15 @@ flex-direction: column; .title { - font-size: var(--text-14); + font-size: 14px; color: var(--heading-color); - font-weight: var(--font-bold); + font-weight: 700; margin-bottom: 8px; line-height: 1; } .description { - font-size: var(--text-14); + font-size: 14px; color: var(--paragraph-color); line-height: 1.5; } @@ -72,7 +72,7 @@ .input { color: var(--slate-900); height: 56px; - font-size: var(--text-16); + font-size: 16px; line-height: 1; &::placeholder { diff --git a/apps/frontend/src/app/Discover/Placeholder/Placeholder.module.scss b/apps/frontend/src/app/Discover/Placeholder/Placeholder.module.scss index 19c34b199..3c58a7189 100644 --- a/apps/frontend/src/app/Discover/Placeholder/Placeholder.module.scss +++ b/apps/frontend/src/app/Discover/Placeholder/Placeholder.module.scss @@ -5,6 +5,6 @@ left: 16px; transform: translateY(-50%); pointer-events: none; - font-size: var(--text-16); + font-size: 16px; line-height: 1; } diff --git a/apps/frontend/src/app/Enrollment/CourseManager/CourseInput/index.tsx b/apps/frontend/src/app/Enrollment/CourseManager/CourseInput/index.tsx index 228984258..49c4dfdbc 100644 --- a/apps/frontend/src/app/Enrollment/CourseManager/CourseInput/index.tsx +++ b/apps/frontend/src/app/Enrollment/CourseManager/CourseInput/index.tsx @@ -3,29 +3,16 @@ import { Dispatch, SetStateAction, useMemo, useRef, useState } from "react"; import { useApolloClient } from "@apollo/client/react"; import { useSearchParams } from "react-router-dom"; -import { - Box, - Button, - Flex, - Option, - OptionItem, - Select, - SelectHandle, -} from "@repo/theme"; - -import CourseSelect, { CourseOption } from "@/components/CourseSelect"; +import { Box, Button, Flex, Select, SelectHandle } from "@repo/theme"; + +import CourseSearch from "@/components/CourseSearch"; import { useReadCourseWithInstructor } from "@/hooks/api"; -import { ICourseWithInstructorClass } from "@/lib/api"; +import { ICourse, ICourseWithInstructorClass } from "@/lib/api"; import { sortByTermDescending } from "@/lib/classes"; import { GetEnrollmentDocument, Semester } from "@/lib/generated/graphql"; import { RecentType, addRecent } from "@/lib/recent"; -import { - LIGHT_COLORS, - Output, - getInputSearchParam, - isInputEqual, -} from "../../types"; +import { Output, getInputSearchParam, isInputEqual } from "../../types"; import styles from "./CourseInput.module.scss"; interface CourseInputProps { @@ -34,11 +21,7 @@ interface CourseInputProps { } // called instructor in frontend but actually we're letting users select a class -type ClassSelectValue = ICourseWithInstructorClass | "all"; -const DEFAULT_SELECTED_CLASS: OptionItem = { - value: "all", - label: "All Instructors", -}; +const DEFAULT_SELECTED_CLASS = { value: null, label: "All Instructors" }; export default function CourseInput({ outputs, setOutputs }: CourseInputProps) { const client = useApolloClient(); @@ -50,43 +33,23 @@ export default function CourseInput({ outputs, setOutputs }: CourseInputProps) { const [loading, setLoading] = useState(false); - const [selectedCourse, setSelectedCourse] = useState( - null - ); + const [selectedCourse, setSelectedCourse] = useState(null); const { data: courseData } = useReadCourseWithInstructor( selectedCourse?.subject ?? "", - selectedCourse?.number ?? "", - { - skip: !selectedCourse, - } + selectedCourse?.number ?? "" ); - const [selectedClass, setSelectedClass] = useState< - ICourseWithInstructorClass | "all" | null - >(null); - const [selectedSemester, setSelectedSemester] = useState(null); - - const buildInstructorList = ( - courseClass: ICourseWithInstructorClass | null - ) => { - if (!courseClass?.primarySection?.meetings) return []; - const names = new Set(); - courseClass.primarySection.meetings.forEach((meeting) => { - meeting.instructors.forEach((instructor) => { - const name = `${instructor.givenName} ${instructor.familyName}`.trim(); - if (name) names.add(name); - }); - }); - return Array.from(names); - }; + const [selectedClass, setSelectedClass] = + useState(DEFAULT_SELECTED_CLASS.value); + const [selectedSemester, setSelectedSemester] = useState(); const semesterOptions = useMemo(() => { // get all semesters const list: { value: string; label: string }[] = []; if (!courseData) return list; const filterHasData = courseData.classes.filter( - ({ primarySection }) => primarySection?.enrollment?.latest + ({ primarySection: { enrollment } }) => enrollment?.latest ); const filteredOptions = filterHasData .filter( @@ -116,7 +79,7 @@ export default function CourseInput({ outputs, setOutputs }: CourseInputProps) { semester: string | null = null, shouldSetSelectedClass = true ) => { - const list: Option[] = [DEFAULT_SELECTED_CLASS]; + const list = [DEFAULT_SELECTED_CLASS]; if (!courseData) return list; const localSelectedSemester = semester ? semester : selectedSemester; @@ -135,18 +98,19 @@ export default function CourseInput({ outputs, setOutputs }: CourseInputProps) { ) ) .forEach((c) => { - const primarySection = c.primarySection; - if (!primarySection?.enrollment?.latest) return; + if (!c.primarySection.enrollment?.latest) return; // only classes from current sem displayed - const instructorNames = buildInstructorList(c); - const instructorLabel = - instructorNames.length > 0 - ? instructorNames.join(", ") - : "All Instructors"; - classStrings.push(`${instructorLabel} ${primarySection.number}`); + let allInstructors = ""; + c.primarySection.meetings.forEach((m) => { + m.instructors.forEach((i) => { + // construct label + allInstructors = `${allInstructors} ${i.familyName}, ${i.givenName};`; + }); + }); + classStrings.push(`${allInstructors} ${c.primarySection.number}`); classes.push(c); }); - const opts: OptionItem[] = classStrings.map((v, i) => { + const opts = classStrings.map((v, i) => { return { value: classes[i], label: v }; }); if (opts.length === 1) { @@ -165,15 +129,7 @@ export default function CourseInput({ outputs, setOutputs }: CourseInputProps) { ]); const add = async () => { - if ( - !selectedClass || - selectedClass === "all" || - !selectedCourse || - !selectedSemester - ) - return; - - if (!selectedClass.primarySection) return; + if (!selectedClass || !selectedCourse || !selectedSemester) return; addRecent(RecentType.Course, { subject: selectedCourse.subject, @@ -182,19 +138,19 @@ export default function CourseInput({ outputs, setOutputs }: CourseInputProps) { const [semester, year] = selectedSemester.split(" "); - const instructors = buildInstructorList(selectedClass); - const input = { subject: selectedCourse.subject, courseNumber: selectedCourse.number, year: parseInt(year), semester: semester as Semester, - sectionNumber: selectedClass.primarySection.number, + sectionNumber: + selectedClass === null + ? undefined + : selectedClass.primarySection.number, sessionId: selectedClass?.semester === "Summer" ? selectedClass.sessionId : undefined, - instructors, }; // Do not fetch duplicates const existingOutput = outputs.find((output) => @@ -208,30 +164,18 @@ export default function CourseInput({ outputs, setOutputs }: CourseInputProps) { try { const response = await client.query({ query: GetEnrollmentDocument, - variables: { - year: input.year, - semester: input.semester, - sessionId: input.sessionId, - subject: input.subject, - courseNumber: input.courseNumber, - sectionNumber: input.sectionNumber, - }, + variables: input, }); if (!response.data || !response.data.enrollment) { throw response.error; } - const usedColors = new Set(outputs.map((output) => output.color)); - const availableColor = - LIGHT_COLORS.find((color) => !usedColors.has(color)) || LIGHT_COLORS[0]; - const output: Output = { hidden: false, active: false, - color: availableColor, // TODO: Error handling - data: response.data!.enrollment, + enrollmentHistory: response.data!.enrollment, input, }; @@ -240,11 +184,6 @@ export default function CourseInput({ outputs, setOutputs }: CourseInputProps) { searchParams.append("input", getInputSearchParam(input)); setSearchParams(searchParams); - // Reset selectors back to defaults after adding a course - setSelectedCourse(null); - setSelectedClass(null); - setSelectedSemester(null); - setLoading(false); } catch { // TODO: Error handling @@ -260,10 +199,10 @@ export default function CourseInput({ outputs, setOutputs }: CourseInputProps) { [loading, outputs] ); - const handleCourseSelect = (course: CourseOption) => { + const handleCourseSelect = (course: ICourse) => { setSelectedCourse(course); - setSelectedClass(null); + setSelectedClass(DEFAULT_SELECTED_CLASS.value); setSelectedSemester(null); semesterSelectRef.current?.focus(); semesterSelectRef.current?.openMenu(); @@ -271,17 +210,20 @@ export default function CourseInput({ outputs, setOutputs }: CourseInputProps) { const handleCourseClear = () => { setSelectedCourse(null); - setSelectedClass(null); + setSelectedClass(DEFAULT_SELECTED_CLASS.value); setSelectedSemester(null); }; return ( - @@ -289,9 +231,6 @@ export default function CourseInput({ outputs, setOutputs }: CourseInputProps) { ref={semesterSelectRef} options={semesterOptions} disabled={disabled || !selectedCourse} - searchable - searchPlaceholder="Search semesters..." - placeholder="Select a semester" value={selectedSemester} onChange={(s) => { if (Array.isArray(s)) return; @@ -306,13 +245,10 @@ export default function CourseInput({ outputs, setOutputs }: CourseInputProps) { /> - + ({ - value: term.value, - label: term.label, - }))} - disabled={!selectedCourse || termOptionsLoading} - loading={termOptionsLoading} - value={selectedTerm} - onChange={(selectedOption) => { - if (Array.isArray(selectedOption)) onTermSelect(null); - else onTermSelect(selectedOption || null); - }} - placeholder={ - selectedCourse ? "Select semester" : "Select a class first" - } - emptyMessage="No semesters found." - clearable={true} - searchable={true} - /> -
-
-
- - - - - ); -} diff --git a/apps/frontend/src/components/Class/Ratings/UserFeedbackModal/RatingModalLayout.tsx b/apps/frontend/src/components/Class/Ratings/UserFeedbackModal/RatingModalLayout.tsx deleted file mode 100644 index d9a6a3ac9..000000000 --- a/apps/frontend/src/components/Class/Ratings/UserFeedbackModal/RatingModalLayout.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { ReactNode, RefObject } from "react"; - -import { Progress } from "radix-ui"; - -import { Dialog } from "@repo/theme"; - -import styles from "./UserFeedbackModal.module.scss"; - -interface RatingModalLayoutProps { - isOpen: boolean; - onClose: () => void; - title: string; - subtitle: string; - progress: number; - children: ReactNode; - footer: ReactNode; - modalBodyClassName?: string; - modalBodyRef?: RefObject; -} - -export function RatingModalLayout({ - isOpen, - onClose, - title, - subtitle, - progress, - children, - footer, - modalBodyClassName, - modalBodyRef, -}: RatingModalLayoutProps) { - return ( - - - - -
- - - - -
- - {children} - - {footer} -
-
-
- ); -} diff --git a/apps/frontend/src/components/Class/Ratings/UserFeedbackModal/UserFeedbackModal.module.scss b/apps/frontend/src/components/Class/Ratings/UserFeedbackModal/UserFeedbackModal.module.scss index e51c62383..0c0736e46 100644 --- a/apps/frontend/src/components/Class/Ratings/UserFeedbackModal/UserFeedbackModal.module.scss +++ b/apps/frontend/src/components/Class/Ratings/UserFeedbackModal/UserFeedbackModal.module.scss @@ -1,121 +1,43 @@ -.modalHeaderWrapper { - position: relative; -} - -.modalHeader { - position: relative; - - // Target the close button (IconButton) within the header - // The Dialog.Close wrapper is the last child, and it contains the IconButton - > :global(:last-child) { - position: absolute; - top: 16px; - right: 16px; - margin: 0; - } -} - -.progressBar { - position: absolute; - bottom: 0; - left: 0; - right: 0; - height: 2px; - background-color: var(--border-color); - overflow: hidden; -} - -.progressIndicator { - width: 100%; - height: 100%; - background-color: var(--blue-500); - transition: transform 0.3s ease; -} - .modalBody { - max-height: 70vh; + max-height: 418px; overflow-y: scroll; - overflow-x: hidden; - position: relative; -} - -.formContentWrapper { - position: relative; - width: 100%; - - &.slideTransition { - animation: slideOut 0.15s ease-out forwards, slideIn 0.15s 0.15s ease-in forwards; - } -} - -@keyframes slideOut { - from { - opacity: 1; - transform: translateX(0); - } - to { - opacity: 0; - transform: translateX(-20px); - } -} - -@keyframes slideIn { - from { - opacity: 0; - transform: translateX(20px); - } - to { - opacity: 1; - transform: translateX(0); - } } .formGroup { + margin-bottom: 20px; padding: 0 84px; + padding-top: 20px; h3, p { - color: var(--secondary-color); - font-size: var(--text-14); - font-weight: var(--font-normal); - margin: 0; - padding: 0; + color: var(--paragraph-color); + font-size: 14px; + font-weight: 400; + margin-bottom: 12px; + padding-bottom: 5px; } .requiredAsterisk { color: #dc2626; margin-left: 4px; - font-size: var(--text-16); + font-size: 16px; line-height: 1; } } -.questionPair { - display: flex; - flex-direction: column; - gap: 12px; - padding: 20px 0; -} - .ratingScale { display: flex; align-items: center; gap: 16px; + margin-top: 5px; max-width: 500px; - margin: 0 auto; + margin-left: clamp(4px, 4%, 18px); + margin-right: auto; span { color: var(--paragraph-color); - font-size: var(--text-14); + font-size: 14px; min-width: 80px; - - &:first-child { - text-align: right; - } - - &:last-child { - text-align: left; - } } } @@ -133,7 +55,7 @@ border-radius: 4px; background: none; color: var(--heading-color); - font-weight: var(--font-medium); + font-weight: 500; cursor: pointer; transition: all 0.2s ease; @@ -166,7 +88,7 @@ align-items: center; gap: 12px; color: var(--paragraph-color); - font-size: var(--text-14); + font-size: 14px; cursor: pointer; white-space: nowrap; @@ -254,7 +176,7 @@ background: none; color: var(--label-color); cursor: pointer; - font-size: var(--text-18); + font-size: 18px; padding: 0; transition: color 0.2s ease; diff --git a/apps/frontend/src/components/Class/Ratings/UserFeedbackModal/index.tsx b/apps/frontend/src/components/Class/Ratings/UserFeedbackModal/index.tsx index 226ae21eb..c8ace1a43 100644 --- a/apps/frontend/src/components/Class/Ratings/UserFeedbackModal/index.tsx +++ b/apps/frontend/src/components/Class/Ratings/UserFeedbackModal/index.tsx @@ -1,75 +1,55 @@ import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; import { MetricName, REQUIRED_METRICS } from "@repo/shared"; -import { Button } from "@repo/theme"; +import { Button, Dialog, Flex, Select } from "@repo/theme"; -import { CourseOption } from "@/components/CourseSelect"; -import { useReadCourseWithInstructor, useReadTerms } from "@/hooks/api"; +import { useReadTerms } from "@/hooks/api"; import { IUserRatingClass } from "@/lib/api"; -import { Semester } from "@/lib/generated/graphql"; +import { Semester, TemporalPosition } from "@/lib/generated/graphql"; import { MetricData, toMetricData } from "../metricsUtil"; -import { RatingFormBody } from "./RatingFormBody"; -import { RatingModalLayout } from "./RatingModalLayout"; -// eslint-disable-next-line css-modules/no-unused-class +import { SubmitRatingPopup } from "./ConfirmationPopups"; +import { AttendanceForm, RatingsForm } from "./FeedbackForm"; import styles from "./UserFeedbackModal.module.scss"; -import { useRatingFormState } from "./useRatingFormState"; -import { useTermFiltering } from "./useTermFiltering"; + +const RequiredAsterisk = () => *; interface Term { value: string; label: string; semester: Semester; year: number; - classNumber?: string; } interface UserFeedbackModalProps { isOpen: boolean; onClose: () => void; title: string; - subtitle?: string; - showSelectedCourseSubtitle?: boolean; - availableTerms?: Term[]; - requiredRatingsCount?: number; - initialCourse?: CourseOption | null; + currentClass: { + subject: string; + courseNumber: string; + number: string; + semester: string; + year: number; + }; + availableTerms: Term[]; onSubmit: ( metricData: MetricData, - termInfo: { semester: Semester; year: number }, - courseInfo: { subject: string; courseNumber: string; classNumber: string } + termInfo: { semester: Semester; year: number } ) => Promise; - initialUserClass?: IUserRatingClass | null; - userRatedClasses?: Array<{ subject: string; courseNumber: string }>; - onSubmitPopupChange?: (isOpen: boolean) => void; - disableRatedCourses?: boolean; - lockedCourse?: CourseOption | null; - onError?: (error: unknown) => void; + initialUserClass: IUserRatingClass | null; } export function UserFeedbackModal({ isOpen, onClose, title, - subtitle, - showSelectedCourseSubtitle = true, + currentClass, availableTerms = [], - requiredRatingsCount = 1, - initialCourse = null, onSubmit, - initialUserClass = null, - userRatedClasses, - onSubmitPopupChange, - disableRatedCourses = true, - lockedCourse = null, - onError, + initialUserClass, }: UserFeedbackModalProps) { - const { data: termsData, loading: termsLoading } = useReadTerms(); - const totalRatings = Math.max(1, requiredRatingsCount); - const [currentRatingIndex, setCurrentRatingIndex] = useState(0); - const [isTransitioning, setIsTransitioning] = useState(false); - const modalBodyRef = useRef(null); - const prevRatingIndexRef = useRef(0); - + const { data: termsData } = useReadTerms(); const initialMetricData = useMemo( () => toMetricData( @@ -81,185 +61,54 @@ export function UserFeedbackModal({ [initialUserClass?.metrics] ); - const initialTermValue = useMemo(() => { - if (initialUserClass?.semester && initialUserClass?.year) { - // Match by semester and year only, find the first matching option - const matchingTerm = availableTerms.find( - (term) => - term.semester === initialUserClass.semester && - term.year === initialUserClass.year - ); - return matchingTerm ? matchingTerm.value : null; - } - return null; - }, [initialUserClass?.semester, initialUserClass?.year, availableTerms]); - - const initialSelectedCourse = useMemo(() => { - const base = - initialUserClass && - initialUserClass.subject && - initialUserClass.courseNumber - ? { - subject: initialUserClass.subject, - number: initialUserClass.courseNumber, - courseId: "", - } - : initialCourse; - if (!base) return null; - return { - subject: base.subject, - number: base.number, - courseId: base.courseId ?? "", - }; - }, [initialCourse, initialUserClass]); - - const formState = useRatingFormState({ - initialMetricData, - initialCourse: initialSelectedCourse, - }); - - const { - metricData, - setMetricData, - selectedTerm, - setSelectedTerm, - selectedCourse, - setSelectedCourse, - isSubmitting, - setIsSubmitting, - progress, - reset, - } = formState; - - const overallProgress = useMemo( - () => ((currentRatingIndex + progress / 100) / totalRatings) * 100, - [currentRatingIndex, progress, totalRatings] + const initialTermValue = useMemo( + () => + initialUserClass?.semester && initialUserClass?.year + ? `${initialUserClass.semester} ${initialUserClass.year}` + : null, + [initialUserClass?.semester, initialUserClass?.year] ); - const { data: courseData, loading: courseLoading } = - useReadCourseWithInstructor( - selectedCourse?.subject ?? "", - selectedCourse?.number ?? "", - { - skip: !selectedCourse, - } - ); - - const { filteredSemesters, hasAutoSelected } = useTermFiltering({ - availableTerms, - termsData: termsData, - selectedCourse, - courseData: courseData ?? undefined, - }); - - const termOptions = filteredSemesters; - // Show loading when: - // 1. Terms data is loading, OR - // 2. A course is selected AND (course data is loading OR we don't have course data yet) - const isTermOptionsLoading = - termsLoading || (!!selectedCourse && (courseLoading || !courseData)); - - const hasHydratedRef = useRef(false); - const lastInitialKeyRef = useRef(null); - - const termOptionsKey = useMemo( - () => termOptions.map((t) => t.value).join("|"), - [termOptions] + const [selectedTerm, setSelectedTerm] = useState( + initialTermValue ); + const [metricData, setMetricData] = useState(initialMetricData); + const [isSubmitting, setIsSubmitting] = useState(false); + const hasAutoSelected = useRef(false); useEffect(() => { - const targetTermOptions = termOptions.length ? termOptions : availableTerms; - const initialCourseKeyValue = initialSelectedCourse - ? `${initialSelectedCourse.subject}-${initialSelectedCourse.number}` - : "no-course"; - const initialKey = initialUserClass - ? `${initialUserClass.subject}-${initialUserClass.courseNumber}-${initialUserClass.semester}-${initialUserClass.year}-${initialCourseKeyValue}` - : `none-${initialCourseKeyValue}`; - - if (hasHydratedRef.current && lastInitialKeyRef.current === initialKey) { - return; - } - - lastInitialKeyRef.current = initialKey; - hasHydratedRef.current = true; - - if (initialUserClass?.semester && initialUserClass?.year) { - // Match by semester and year only, find the first matching option - const matchingTerm = targetTermOptions.find( - (term) => - term.semester === initialUserClass.semester && - term.year === initialUserClass.year + if ( + initialUserClass?.semester && + initialUserClass?.year && + initialUserClass?.classNumber + ) + setSelectedTerm( + `${initialUserClass.semester} ${initialUserClass.year} ${initialUserClass.classNumber}` ); - if (matchingTerm) { - setSelectedTerm(matchingTerm.value); - } else { - setSelectedTerm(null); - } - } else { - // Reset to null when initialUserClass is null (after deletion) - setSelectedTerm(null); - } - - if (initialUserClass?.metrics) { + if (initialUserClass?.metrics) setMetricData(toMetricData(initialUserClass.metrics)); - } else { - // Reset to initial empty state when initialUserClass is null (after deletion) - setMetricData(initialMetricData); - } - setSelectedCourse(initialSelectedCourse); - setCurrentRatingIndex(0); - }, [ - initialUserClass, - availableTerms, - termOptionsKey, - initialMetricData, - setMetricData, - setSelectedTerm, - setSelectedCourse, - initialSelectedCourse, - ]); - - const initialCourseKey = initialSelectedCourse - ? `${initialSelectedCourse.subject}-${initialSelectedCourse.number}` - : ""; - const currentCourseKey = selectedCourse - ? `${selectedCourse.subject}-${selectedCourse.number}` - : ""; + }, [initialUserClass]); const hasChanges = useMemo(() => { const termChanged = selectedTerm !== initialTermValue; const metricsChanged = Object.values(MetricName).some( (metric) => metricData[metric] !== initialMetricData[metric] ); - const courseChanged = currentCourseKey !== initialCourseKey; - // Check if all required metrics are filled out const allRequiredMetricsFilled = REQUIRED_METRICS.every( (metric) => typeof metricData[metric] === "number" ); - return ( - allRequiredMetricsFilled && - (termChanged || metricsChanged || courseChanged) - ); - }, [ - selectedTerm, - metricData, - initialTermValue, - initialMetricData, - currentCourseKey, - initialCourseKey, - ]); + return allRequiredMetricsFilled && (termChanged || metricsChanged); + }, [selectedTerm, metricData, initialTermValue, initialMetricData]); const isFormValid = useMemo(() => { const isTermValid = selectedTerm && selectedTerm.length > 0; - const isCourseValid = !!selectedCourse; const areRatingsValid = typeof metricData[MetricName.Usefulness] === "number" && typeof metricData[MetricName.Difficulty] === "number" && typeof metricData[MetricName.Workload] === "number"; - - return isCourseValid && isTermValid && areRatingsValid && hasChanges; - }, [selectedTerm, metricData, hasChanges, selectedCourse]); + return isTermValid && areRatingsValid && hasChanges; + }, [selectedTerm, metricData, hasChanges]); const handleSubmit = async (e: FormEvent) => { e.preventDefault(); @@ -267,190 +116,152 @@ export function UserFeedbackModal({ setIsSubmitting(true); try { - const selectedTermInfo = termOptions.find( + const selectedTermInfo = availableTerms.find( (t) => t.value === selectedTerm ); if (!selectedTermInfo) throw new Error("Invalid term selected"); - const termParts = selectedTerm?.trim().split(" ") || []; - const parsedClassNumber = termParts[termParts.length - 1] || ""; - const classNumber = selectedTermInfo.classNumber ?? parsedClassNumber; - if (!classNumber) { - throw new Error( - "Invalid term selection - class number could not be determined." - ); - } - - if (!selectedCourse) { - throw new Error("A course must be selected."); - } - - await onSubmit( - metricData, - { - semester: selectedTermInfo.semester, - year: selectedTermInfo.year, - }, - { - subject: selectedCourse.subject, - courseNumber: selectedCourse.number, - classNumber, - } - ); + await onSubmit(metricData, { + semester: selectedTermInfo.semester, + year: selectedTermInfo.year, + }); - const isLastRating = currentRatingIndex >= totalRatings - 1; - if (!isLastRating) { - setCurrentRatingIndex((idx) => idx + 1); - reset(initialMetricData, null); - setSelectedTerm(null); - setSelectedCourse(null); - return; - } - - setCurrentRatingIndex(0); - handleClose(); - onSubmitPopupChange?.(true); + onClose(); + setIsSubmitRatingPopupOpen(true); } catch (error) { console.error("Error submitting ratings:", error); - onError?.(error); } finally { setIsSubmitting(false); } }; + const [isSubmitRatingPopupOpen, setIsSubmitRatingPopupOpen] = useState(false); + + // Filter for past terms + const pastTerms = useMemo(() => { + if (!termsData) return availableTerms; + + const termPositions = termsData.reduce( + (acc: Record, term: any) => { + acc[`${term.semester} ${term.year}`] = term.temporalPosition; + return acc; + }, + {} + ); + return availableTerms.filter((term) => { + const position = termPositions[term.value]; + return ( + position === TemporalPosition.Past || + TemporalPosition.Current || + !position + ); + }); + }, [availableTerms, termsData]); + // Auto-select term if only one option is available useEffect(() => { - if (termOptions.length === 1 && !selectedTerm && !hasAutoSelected.current) { - setSelectedTerm(termOptions[0].value); + if (pastTerms.length === 1 && !selectedTerm && !hasAutoSelected.current) { + setSelectedTerm(pastTerms[0].value); hasAutoSelected.current = true; } - }, [termOptions, selectedTerm, setSelectedTerm, hasAutoSelected]); + }, [pastTerms, selectedTerm]); const handleClose = () => { - reset(initialMetricData, initialSelectedCourse); - setSelectedTerm(initialTermValue); - setCurrentRatingIndex(0); - prevRatingIndexRef.current = 0; + // Reset form state to initial values when closing + setMetricData(initialMetricData); + setSelectedTerm(null); hasAutoSelected.current = false; // Reset the auto-selection flag when closing onClose(); }; - // Calculate modal title and subtitle - const modalTitle = title; - const modalSubtitle = - subtitle !== undefined - ? subtitle - : showSelectedCourseSubtitle && selectedCourse - ? `${selectedCourse.subject} ${selectedCourse.number}` - : ""; - - // Calculate question numbers - const questionNumbers = useMemo(() => { - let counter = 1; - const classQuestion = counter++; - const semesterQuestion = counter++; - const ratingsStart = counter; - counter += 3; // 3 rating questions - const attendanceStart = counter; - - return { - classQuestionNumber: classQuestion, - semesterQuestionNumber: semesterQuestion, - ratingsStartNumber: ratingsStart, - attendanceStartNumber: attendanceStart, - }; - }, []); - - // Handle slide animation when moving to next rating - useEffect(() => { - const prevIndex = prevRatingIndexRef.current; - - // Only animate if rating index increased (Submit & Continue was clicked) - if (currentRatingIndex > prevIndex && prevIndex >= 0) { - setIsTransitioning(true); - - // Scroll to top of modal body - if (modalBodyRef.current) { - modalBodyRef.current.scrollTop = 0; - } - - const timeout = setTimeout(() => { - setIsTransitioning(false); - }, 300); - - return () => clearTimeout(timeout); - } - - prevRatingIndexRef.current = currentRatingIndex; - }, [currentRatingIndex]); - - const remainingRatings = Math.max(0, totalRatings - currentRatingIndex - 1); - const submitLabel = isSubmitting - ? "Submitting..." - : totalRatings > 1 - ? remainingRatings > 0 - ? `Submit & Continue (${remainingRatings} left)` - : "Submit" - : initialUserClass - ? "Submit Edit" - : "Submit Rating"; - - const footer = ( - <> - - - - ); - return ( <> - -
- { - setSelectedCourse(course); - setSelectedTerm(null); - }} - onCourseClear={() => { - setSelectedCourse(null); - setSelectedTerm(null); - }} - selectedTerm={selectedTerm} - onTermSelect={setSelectedTerm} - termOptions={termOptions} - termOptionsLoading={isTermOptionsLoading} - metricData={metricData} - setMetricData={setMetricData} - userRatedClasses={userRatedClasses} - questionNumbers={questionNumbers} - disableRatedCourses={disableRatedCourses} - lockedCourse={lockedCourse} - /> -
-
+ + + + + + + +
+

+ 1. What semester did you take this course?{" "} + +

+
+ { - setActiveRatingTab(tabValue); - // Clear displayed ratings data when browsing tabs, but preserve selection - setTermRatings(null); - }} - onChange={(newValue) => { - if (Array.isArray(newValue) || !newValue) return; // ensure it is string - - setSelectedValue(newValue); - - // Handle tab-specific data fetching - if (activeRatingTab === RATING_TABS.Semester) { - // Semester tab - if (newValue === "all") { - setTermRatings(null); - } else if (isSemester(newValue)) { - const [semester, year] = newValue.split(" "); - const selectedClass = courseClasses.find( - (c) => - c.semester === semester && - c.year === parseInt(year) - ); - if ( - selectedClass && - selectedClass.aggregatedRatings - ) { - setTermRatings(selectedClass.aggregatedRatings); - } - } - } else { - // Instructor tab - if (newValue === "all") { - setTermRatings(null); - } else { - const selectedInstructor = - instructorAggregatedRatings?.find((rating) => { - if (!rating) return false; - const key = `${rating.instructor.givenName}_${rating.instructor.familyName}`; - return key === newValue; - }); - if ( - selectedInstructor && - selectedInstructor.aggregatedRatings - ) { - setTermRatings( - selectedInstructor.aggregatedRatings - ); - } - } + value={selectedTerm} + variant="foreground" + onChange={async (selectedValue) => { + if (Array.isArray(selectedValue) || !selectedValue) + return; // ensure it is string + setSelectedTerm(selectedValue); + if (selectedValue === "all") { + setTermRatings(null); + } else if (isSemester(selectedValue)) { + const [semester, year] = selectedValue.split(" "); + const { data } = await getAggregatedRatings({ + variables: { + subject: currentClass.subject, + courseNumber: currentClass.courseNumber, + semester: semester, + year: parseInt(year), + }, + }); + if (data) setTermRatings(data); } }} - placeholder={ - activeRatingTab === RATING_TABS.Instructor - ? "Select instructor" - : "Select term" - } + placeholder="Select term" /> )}
@@ -548,6 +536,15 @@ export function RatingsContainer() {
))}
+ {/* // TODO: [CROWD-SOURCED-DATA] add rating count for semester instance */} + {/*
+ {hasRatings && ratingsData && ( +
+ This semester has been rated by {ratingsCount} user + {ratingsCount !== 1 ? "s" : ""} +
+ )} +
*/}
)} @@ -556,47 +553,24 @@ export function RatingsContainer() { isOpen={isModalOpen} onClose={() => handleModalStateChange(false)} title={userRatings ? "Edit Rating" : "Rate Course"} - subtitle="" - showSelectedCourseSubtitle={false} - initialCourse={{ - subject: currentClass.subject, - number: currentClass.courseNumber, - courseId: "", - }} + currentClass={currentClass} availableTerms={availableTerms} - onSubmit={async (metricValues, termInfo, courseInfo) => { - await submitRatingMutation({ - metricValues, - termInfo, - createRatingsMutation, - classIdentifiers: { - subject: courseInfo.subject, - courseNumber: courseInfo.courseNumber, - number: courseInfo.classNumber, - }, - refetchQueries: [], - }); - refetchAllRatings(); - setIsModalOpen(false); + onSubmit={async (metricValues, termInfo) => { + try { + await ratingSubmit( + metricValues, + termInfo, + createRating, + deleteRating, + currentClass, + setIsModalOpen, + userRatings + ); + } catch (error) { + console.error("Error submitting rating:", error); + } }} initialUserClass={userRatings} - userRatedClasses={userRatedClasses} - disableRatedCourses={!userRatings} - lockedCourse={ - userRatings - ? { - subject: currentClass.subject, - number: currentClass.courseNumber, - courseId: "", - } - : null - } - onSubmitPopupChange={setIsSubmitRatingPopupOpen} - onError={(error) => { - const message = getRatingErrorMessage(error); - setErrorMessage(message); - setIsErrorDialogOpen(true); - }} /> { if (userRatings) { - try { - await deleteRatingHelper({ - deleteRatingsMutation, - classIdentifiers: { - subject: currentClass.subject, - courseNumber: currentClass.courseNumber, - number: currentClass.number, - }, - refetchQueries: [], - }); - refetchAllRatings(); - setIsDeleteModalOpen(false); - } catch (error) { - const message = getRatingErrorMessage(error); - setErrorMessage(message); - setIsErrorDialogOpen(true); - } + await ratingDelete(userRatings, currentClass, deleteRating); } }} /> - - setIsErrorDialogOpen(false)} - errorMessage={errorMessage} - /> - setIsSubmitRatingPopupOpen(false)} - /> ); } diff --git a/apps/frontend/src/components/Class/Ratings/metricsUtil.ts b/apps/frontend/src/components/Class/Ratings/metricsUtil.ts index d9b174c53..b08cd4e05 100644 --- a/apps/frontend/src/components/Class/Ratings/metricsUtil.ts +++ b/apps/frontend/src/components/Class/Ratings/metricsUtil.ts @@ -21,23 +21,27 @@ export function isMetricRating(metricName: MetricName) { return METRIC_MAPPINGS[metricName]?.isRating ?? false; } -const COLOR_THRESHOLDS = [ - { min: 4.3, inverse: "red", normal: "green" }, - { min: 3.5, inverse: "orange", normal: "lime" }, - { min: 2.7, inverse: "yellow", normal: "yellow" }, - { min: 1.9, inverse: "lime", normal: "orange" }, - { min: 0, inverse: "green", normal: "red" }, -]; - export function getStatusColor( metricName: MetricName, weightedAverage: number ): string { - const isInverse = METRIC_MAPPINGS[metricName]?.isInverseRelationship; - const threshold = - COLOR_THRESHOLDS.find((t) => weightedAverage >= t.min) ?? - COLOR_THRESHOLDS[COLOR_THRESHOLDS.length - 1]; - return isInverse ? threshold.inverse : threshold.normal; + // For usefulness (not inverse relationship), high numbers should be green + // For difficulty and workload (inverse relationship), high numbers should be red + if (weightedAverage >= 4.3) { + return METRIC_MAPPINGS[metricName]?.isInverseRelationship ? "red" : "green"; + } else if (weightedAverage >= 3.5) { + return METRIC_MAPPINGS[metricName]?.isInverseRelationship + ? "orange" + : "lime"; + } else if (weightedAverage >= 2.7) { + return "yellow"; + } else if (weightedAverage >= 1.9) { + return METRIC_MAPPINGS[metricName]?.isInverseRelationship + ? "lime" + : "orange"; + } else { + return METRIC_MAPPINGS[metricName]?.isInverseRelationship ? "green" : "red"; + } } export function formatDate(date: Date): string { @@ -122,48 +126,3 @@ export const checkConstraint = ( ); return otherClasses.length <= USER_MAX_ALL_RATINGS; }; - -/** - * Formats instructor names from a course class's primary section. - */ -export function formatInstructorText( - primarySection: - | { - meetings?: Array<{ - instructors?: Array<{ - givenName?: string | null; - familyName?: string | null; - }>; - }>; - } - | null - | undefined -): string { - if (!primarySection || !primarySection.meetings) { - return "(No instructor)"; - } - - const allInstructors: Array<{ givenName: string; familyName: string }> = []; - primarySection.meetings.forEach((meeting) => { - if (meeting.instructors) { - meeting.instructors.forEach((instructor) => { - if (instructor.familyName && instructor.givenName) { - allInstructors.push({ - givenName: instructor.givenName, - familyName: instructor.familyName, - }); - } - }); - } - }); - - if (allInstructors.length === 0) { - return "(No instructor)"; - } - - // Show all instructors (now that we filter to professors only) - const names = allInstructors - .map((i) => `${i.givenName} ${i.familyName}`) - .join(", "); - return `(${names})`; -} diff --git a/apps/frontend/src/components/Class/Ratings/ratingMutations.ts b/apps/frontend/src/components/Class/Ratings/ratingMutations.ts deleted file mode 100644 index 37e00917c..000000000 --- a/apps/frontend/src/components/Class/Ratings/ratingMutations.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { DocumentNode } from "graphql"; - -import { MetricName, REQUIRED_METRICS } from "@repo/shared"; - -import { Semester } from "@/lib/generated/graphql"; - -import { MetricData } from "./metricsUtil"; - -const METRIC_NAMES = Object.values(MetricName) as MetricName[]; - -export interface ClassIdentifiers { - subject: string; - courseNumber: string; - number: string; -} - -type RefetchQuery = { - query: DocumentNode; - variables?: Record; -}; - -type MutationFn = (options: { - variables: Record; - refetchQueries?: RefetchQuery[]; - awaitRefetchQueries?: boolean; -}) => Promise; - -interface SubmitRatingOptions { - metricValues: MetricData; - termInfo: { semester: Semester; year: number }; - createRatingsMutation: MutationFn; - classIdentifiers: ClassIdentifiers; - refetchQueries?: RefetchQuery[]; -} - -export async function submitRating({ - metricValues, - termInfo, - createRatingsMutation, - classIdentifiers, - refetchQueries = [], -}: SubmitRatingOptions) { - // Validate required metrics are present - const missingRequiredMetrics = REQUIRED_METRICS.filter( - (metric) => - metricValues[metric] === null || metricValues[metric] === undefined - ); - if (missingRequiredMetrics.length > 0) { - throw new Error( - `Missing required metrics: ${missingRequiredMetrics.join(", ")}` - ); - } - - const metrics = METRIC_NAMES.filter( - (metric) => - metricValues[metric] !== null && metricValues[metric] !== undefined - ).map((metric) => ({ - metricName: metric, - value: metricValues[metric] as number, - })); - - if (metrics.length === 0) { - throw new Error("No populated metrics"); - } - - await createRatingsMutation({ - variables: { - subject: classIdentifiers.subject, - courseNumber: classIdentifiers.courseNumber, - semester: termInfo.semester, - year: termInfo.year, - classNumber: classIdentifiers.number, - metrics, - }, - refetchQueries, - awaitRefetchQueries: true, - }); -} - -interface DeleteRatingOptions { - deleteRatingsMutation: MutationFn; - classIdentifiers: ClassIdentifiers; - refetchQueries?: RefetchQuery[]; -} - -export async function deleteRating({ - deleteRatingsMutation, - classIdentifiers, - refetchQueries = [], -}: DeleteRatingOptions) { - await deleteRatingsMutation({ - variables: { - subject: classIdentifiers.subject, - courseNumber: classIdentifiers.courseNumber, - }, - refetchQueries, - awaitRefetchQueries: true, - }); -} diff --git a/apps/frontend/src/components/Class/Sections/Sections.module.scss b/apps/frontend/src/components/Class/Sections/Sections.module.scss index 1eb216795..1e8323c92 100644 --- a/apps/frontend/src/components/Class/Sections/Sections.module.scss +++ b/apps/frontend/src/components/Class/Sections/Sections.module.scss @@ -2,7 +2,40 @@ display: flex; flex-direction: column; gap: 10px; - width: 100%; + padding: 20px 24px; + height: 100%; + box-sizing: border-box; +} + +.placeholder { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + color: var(--label-color); + height: 100%; + font-size: var(--text-16); + font-weight: var(--font-normal); + line-height: 1.5; + padding: 24px 0; + padding-top: 150px; + text-align: center; + + .heading { + color: var(--heading-color); + font-weight: var(--font-medium); + font-size: var(--text-16); + margin-top: 24px; + margin-bottom: 0; + } + + .paragraph { + color: var(--paragraph-color); + margin: 8px 0 0; + max-width: 448px; + font-size: var(--text-14); + font-weight: var(--font-normal); + } } .table { @@ -31,10 +64,6 @@ .row { height: 120px; border-bottom: 1px solid var(--border-color); - - &:last-child { - border-bottom: none; - } } .cell { @@ -83,6 +112,10 @@ font-weight: var(--font-medium); &:hover { - color: var(--blue-hover); + color: var(--blue-600); + + @media (prefers-color-scheme: dark) { + color: var(--blue-400); + } } } diff --git a/apps/frontend/src/components/Class/Sections/index.tsx b/apps/frontend/src/components/Class/Sections/index.tsx index 48d43ab97..536ac4f1b 100644 --- a/apps/frontend/src/components/Class/Sections/index.tsx +++ b/apps/frontend/src/components/Class/Sections/index.tsx @@ -2,12 +2,10 @@ import { useEffect, useMemo, useState } from "react"; import { FrameAltEmpty } from "iconoir-react"; -import { Box, Container, PillSwitcher } from "@repo/theme"; +import { PillSwitcher } from "@repo/theme"; import { getEnrollmentColor } from "@/components/Capacity"; -import EmptyState from "@/components/Class/EmptyState"; import Time from "@/components/Time"; -import { useGetClassSections } from "@/hooks/api/classes/useGetClass"; import useClass from "@/hooks/useClass"; import { componentMap } from "@/lib/api"; import { Component } from "@/lib/generated/graphql"; @@ -54,24 +52,15 @@ const getLocationLink = (location?: string) => { export default function Sections() { const { class: _class } = useClass(); - const { data, loading } = useGetClassSections( - _class.year, - _class.semester, - _class.subject, - _class.courseNumber, - _class.number - ); - - const sections = data?.sections ?? []; // Group sections by component type const groups = useMemo(() => { - const sortedSections = sections.toSorted((a, b) => + const sortedSections = _class.sections.toSorted((a, b) => a.number.localeCompare(b.number) ); return Object.groupBy(sortedSections, (section) => section.component); - }, [sections]); + }, [_class]); // Generate tab items from available component types const tabItems = useMemo(() => { @@ -92,156 +81,139 @@ export default function Sections() { } }, [tabItems, activeTab]); - if (loading) { - return ; - } - - if (sections.length === 0) { + if (_class.sections.length === 0) { return ( - } - heading="No Associated Sections" - paragraph={ - <> - This class doesn't list any sections yet. -
- Section details will appear here once they're available. - - } - /> +
+ +

No Associated Sections

+

+ This class doesn't list any sections yet. +
+ Section details will appear here once they're available. +

+
); } return ( - - -
- - - - - - - - -
- CCN - - Time - - Location - - Waitlist - + + + + + + + + + + + + + {activeSections.map((section) => { + const enrolledCount = section.enrollment?.latest?.enrolledCount; + const maxEnroll = section.enrollment?.latest?.maxEnroll; + const waitlistedCount = section.enrollment?.latest?.waitlistedCount; + const firstMeeting = section.meetings[0]; + const hasTimeData = Boolean( + firstMeeting?.days?.some((day) => day) && + firstMeeting?.endTime && + hasValidStartTime(firstMeeting?.startTime) + ); + const locationValue = + typeof firstMeeting?.location === "string" + ? firstMeeting.location.trim() + : ""; + const locationLink = getLocationLink(locationValue); + + // Calculate enrollment percentage + const enrollmentPercentage = + typeof enrolledCount === "number" && + typeof maxEnroll === "number" && + maxEnroll > 0 + ? Math.round((enrolledCount / maxEnroll) * 100) + : null; + + const enrollmentColor = getEnrollmentColor( + enrolledCount, + maxEnroll + ); + + return ( + + + + + + - - - {activeSections.map((section) => { - const enrolledCount = section.enrollment?.latest?.enrolledCount; - const maxEnroll = section.enrollment?.latest?.maxEnroll; - const waitlistedCount = - section.enrollment?.latest?.waitlistedCount; - const firstMeeting = section.meetings[0]; - const hasTimeData = Boolean( - firstMeeting?.days?.some((day) => day) && - firstMeeting?.endTime && - hasValidStartTime(firstMeeting?.startTime) - ); - const locationValue = - typeof firstMeeting?.location === "string" - ? firstMeeting.location.trim() - : ""; - const locationLink = getLocationLink(locationValue); - - // Calculate enrollment percentage - const enrollmentPercentage = - typeof enrolledCount === "number" && - typeof maxEnroll === "number" && - maxEnroll > 0 - ? Math.round((enrolledCount / maxEnroll) * 100) - : null; - - const enrollmentColor = getEnrollmentColor( - enrolledCount, - maxEnroll - ); - - return ( - - - - - - - - ); - })} - -
+ CCN + + Time + + Location + + Waitlist + + Enrolled +
+ {section.sectionId} + + {hasTimeData ? ( + + {locationLink ? ( + + {locationLink.label} + + ) : ( + locationValue || NO_DATA_LABEL + )} + + {typeof waitlistedCount === "number" + ? waitlistedCount + : NO_DATA_LABEL} + - Enrolled - + {enrollmentPercentage !== null + ? `${enrollmentPercentage}% enrolled` + : NO_DATA_LABEL} +
- {section.sectionId} - - {hasTimeData ? ( - - {locationLink ? ( - - {locationLink.label} - - ) : ( - locationValue || NO_DATA_LABEL - )} - - {typeof waitlistedCount === "number" - ? waitlistedCount - : NO_DATA_LABEL} - - {enrollmentPercentage !== null - ? `${enrollmentPercentage}% enrolled` - : NO_DATA_LABEL} -
- - - + ); + })} + +
+
); } diff --git a/apps/frontend/src/components/Class/index.tsx b/apps/frontend/src/components/Class/index.tsx index 34fb89571..b877a8f49 100644 --- a/apps/frontend/src/components/Class/index.tsx +++ b/apps/frontend/src/components/Class/index.tsx @@ -1,57 +1,42 @@ -import { - ReactNode, - lazy, - useCallback, - useEffect, - useMemo, - useState, -} from "react"; - -import { useMutation, useQuery } from "@apollo/client/react"; +import { ReactNode, lazy, useCallback, useEffect, useMemo } from "react"; + import classNames from "classnames"; -import { Bookmark, BookmarkSolid, OpenNewWindow } from "iconoir-react"; +import { + Bookmark, + BookmarkSolid, + CalendarPlus, + InfoCircle, + OpenNewWindow, +} from "iconoir-react"; import { Tabs } from "radix-ui"; -import { Link, NavLink, useLocation, useNavigate } from "react-router-dom"; +import { Link, NavLink, Outlet, useLocation } from "react-router-dom"; -import { MetricName, REQUIRED_METRICS } from "@repo/shared"; -import { USER_REQUIRED_RATINGS_TO_UNLOCK } from "@repo/shared"; import { + Badge, Box, + Color, Container, Flex, IconButton, MenuItem, - Tooltip as ThemeTooltip, + Tooltip, } from "@repo/theme"; import { AverageGrade } from "@/components/AverageGrade"; import CCN from "@/components/CCN"; -import { - ErrorDialog, - SubmitRatingPopup, -} from "@/components/Class/Ratings/RatingDialog"; import EnrollmentDisplay from "@/components/EnrollmentDisplay"; -import { ReservedSeatingHoverCard } from "@/components/ReservedSeatingHoverCard"; import Units from "@/components/Units"; import ClassContext from "@/contexts/ClassContext"; -import { useGetClassOverview, useUpdateUser } from "@/hooks/api"; -import { useGetClass } from "@/hooks/api/classes/useGetClass"; +import { useReadCourseForClass, useUpdateUser } from "@/hooks/api"; +import { useReadClass } from "@/hooks/api/classes/useReadClass"; import useUser from "@/hooks/useUser"; -import { IClassCourse, IClassDetails, signIn } from "@/lib/api"; -import { - CreateRatingsDocument, - GetUserRatingsDocument, - Semester, -} from "@/lib/generated/graphql"; +import { IClass, IClassCourse } from "@/lib/api"; +import { Semester } from "@/lib/generated/graphql"; import { RecentType, addRecent } from "@/lib/recent"; import { getExternalLink } from "@/lib/section"; -import { getRatingErrorMessage } from "@/utils/ratingErrorMessages"; import SuspenseBoundary from "../SuspenseBoundary"; import styles from "./Class.module.scss"; -import UserFeedbackModal from "./Ratings/UserFeedbackModal"; -import { MetricData } from "./Ratings/metricsUtil"; -import { type RatingsTabClasses, RatingsTabLink } from "./locks"; const Enrollment = lazy(() => import("./Enrollment")); const Grades = lazy(() => import("./Grades")); @@ -59,6 +44,15 @@ const Overview = lazy(() => import("./Overview")); const Sections = lazy(() => import("./Sections")); const Ratings = lazy(() => import("./Ratings")); +interface BodyProps { + children: ReactNode; + dialog?: boolean; +} + +function Body({ children, dialog }: BodyProps) { + return dialog ? children : ; +} + interface RootProps { dialog?: boolean; children: ReactNode; @@ -75,7 +69,7 @@ function Root({ dialog, children }: RootProps) { } interface ControlledProps { - class: IClassDetails; + class: IClass; course?: IClassCourse; year?: never; semester?: never; @@ -97,17 +91,6 @@ interface UncontrolledProps { // TODO: Determine whether a controlled input is even necessary type ClassProps = { dialog?: boolean } & (ControlledProps | UncontrolledProps); -const ratingsTabClasses: RatingsTabClasses = { - badge: styles.badge, - dot: styles.dot, - tooltipArrow: styles.tooltipArrow, - tooltipContent: styles.tooltipContent, - tooltipDescription: styles.tooltipDescription, - tooltipTitle: styles.tooltipTitle, -}; - -const METRIC_NAMES = Object.values(MetricName) as MetricName[]; - const formatClassNumber = (number: string | undefined | null): string => { if (!number) return ""; const num = parseInt(number, 10); @@ -117,14 +100,6 @@ const formatClassNumber = (number: string | undefined | null): string => { return num.toString().padStart(2, "0"); }; -const getCurrentTab = (pathname: string): string => { - if (pathname.endsWith("/sections")) return "sections"; - if (pathname.endsWith("/grades")) return "grades"; - if (pathname.endsWith("/ratings")) return "ratings"; - if (pathname.endsWith("/enrollment")) return "enrollment"; - return "overview"; -}; - export default function Class({ year, semester, @@ -137,35 +112,12 @@ export default function Class({ }: ClassProps) { // const { pins, addPin, removePin } = usePins(); const location = useLocation(); - const navigate = useNavigate(); const { user, loading: userLoading } = useUser(); - const [visitedTabs, setVisitedTabs] = useState>(() => { - return new Set([getCurrentTab(location.pathname)]); - }); - - useEffect(() => { - const currentTab = getCurrentTab(location.pathname); - setVisitedTabs((prev) => { - if (prev.has(currentTab)) return prev; - return new Set(prev).add(currentTab); - }); - }, [location.pathname]); - - const { data: userRatingsData } = useQuery(GetUserRatingsDocument, { - skip: !user, - }); - - const [createUnlockRatings] = useMutation(CreateRatingsDocument); const [updateUser] = useUpdateUser(); - const [isUnlockModalOpen, setIsUnlockModalOpen] = useState(false); - const [unlockModalGoalCount, setUnlockModalGoalCount] = useState(0); - const [isUnlockThankYouOpen, setIsUnlockThankYouOpen] = useState(false); - const [errorMessage, setErrorMessage] = useState(""); - const [isErrorDialogOpen, setIsErrorDialogOpen] = useState(false); - const { data: course } = useGetClassOverview( + const { data: course, loading: courseLoading } = useReadCourseForClass( providedClass?.subject ?? (subject as string), providedClass?.courseNumber ?? (courseNumber as string), { @@ -173,7 +125,7 @@ export default function Class({ } ); - const { data } = useGetClass( + const { data, loading } = useReadClass( year as number, semester as Semester, subject as string, @@ -186,61 +138,29 @@ export default function Class({ ); const _class = useMemo(() => providedClass ?? data, [data, providedClass]); - const primarySection = _class?.primarySection ?? null; - - const _course = useMemo( - () => providedCourse ?? course, - [course, providedCourse] - ); - - type ClassSectionAttribute = NonNullable< - NonNullable["sectionAttributes"] - >[number]; - const sectionAttributes = useMemo( - () => _class?.primarySection?.sectionAttributes ?? [], - [_class?.primarySection?.sectionAttributes] - ); + useEffect(() => { + if (!_class?.primarySection?.enrollment) return; - const specialTitleAttribute = useMemo( - () => - sectionAttributes.find( - (attr) => - attr.attribute?.code === "NOTE" && - attr.attribute?.formalDescription === "Special Title" - ), - [sectionAttributes] - ); + const enrollment = _class.primarySection.enrollment; + const seatReservationTypes = enrollment.seatReservationTypes ?? []; + const seatReservationCounts = enrollment.latest?.seatReservationCount ?? []; - const classTitle = useMemo(() => { - if (specialTitleAttribute?.value?.formalDescription) { - return specialTitleAttribute.value.formalDescription; + if (seatReservationCounts.length === 0) { + return; } - return _course?.title ?? ""; - }, [specialTitleAttribute?.value?.formalDescription, _course?.title]); + const typeMap = new Map(); + seatReservationTypes.forEach((type) => { + typeMap.set(type.number, type.requirementGroup); + }); + }, [_class]); - const userRatingsCount = useMemo( - () => userRatingsData?.userRatings?.classes?.length ?? 0, - [userRatingsData] + const _course = useMemo( + () => providedCourse ?? course, + [course, providedCourse] ); - const userRatedClasses = useMemo(() => { - const ratedClasses = - userRatingsData?.userRatings?.classes?.map((cls) => ({ - subject: cls.subject, - courseNumber: cls.courseNumber, - })) ?? []; - - const seen = new Set(); - return ratedClasses.filter((cls) => { - const key = `${cls.subject}-${cls.courseNumber}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); - }, [userRatingsData]); - const bookmarked = useMemo( () => user?.bookmarkedClasses.some( @@ -257,19 +177,6 @@ export default function Class({ const bookmark = useCallback(async () => { if (!user || !_class) return; - const bookmarkEntry: (typeof user.bookmarkedClasses)[number] = { - __typename: "Class", - ..._class, - course: { - __typename: "Course", - title: _course?.title ?? "", - }, - gradeDistribution: { - __typename: "GradeDistribution", - average: _course?.gradeDistribution?.average ?? null, - }, - }; - const bookmarkedClasses = bookmarked ? user.bookmarkedClasses.filter( (bookmarkedClass) => @@ -281,7 +188,7 @@ export default function Class({ bookmarkedClass.semester === _class?.semester ) ) - : user.bookmarkedClasses.concat(bookmarkEntry); + : user.bookmarkedClasses.concat(_class); await updateUser( { bookmarkedClasses: bookmarkedClasses.map((bookmarkedClass) => ({ @@ -302,7 +209,7 @@ export default function Class({ }, } ); - }, [_class, _course, bookmarked, updateUser, user]); + }, [_class, bookmarked, updateUser, user]); useEffect(() => { if (!_class) return; @@ -316,124 +223,17 @@ export default function Class({ }); }, [_class]); - const ratingsCount = useMemo(() => { - const aggregatedRatings = _course?.aggregatedRatings; - if (!aggregatedRatings) { - return false; - } - - type Metric = NonNullable< - NonNullable["metrics"] - >[number]; - const metrics = - (aggregatedRatings.metrics ?? []).filter((metric): metric is Metric => - Boolean(metric) - ) ?? []; - if (metrics.length === 0) { - return false; - } - - const counts = metrics.map((metric) => metric.count); - return counts.length > 0 ? Math.max(...counts) : false; + const ratingsCount = useMemo(() => { + return ( + _course && + _course.aggregatedRatings && + _course.aggregatedRatings.metrics.length > 0 && + Math.max( + ...Object.values(_course.aggregatedRatings.metrics.map((v) => v.count)) + ) + ); }, [_course]); - const ratingsLockContext = useMemo(() => { - if (!user) { - return { - requiresLogin: true, - requiredRatingsCount: USER_REQUIRED_RATINGS_TO_UNLOCK, - }; - } - return { - userRatingsCount, - requiredRatingsCount: USER_REQUIRED_RATINGS_TO_UNLOCK, - }; - }, [user, userRatingsCount]); - const shouldShowRatingsTab = RatingsTabLink.shouldDisplay(ratingsLockContext); - const ratingsLocked = RatingsTabLink.isLocked(ratingsLockContext); - const ratingsNeeded = RatingsTabLink.ratingsNeeded(ratingsLockContext) ?? 0; - - useEffect(() => { - if (dialog || !ratingsLocked) return; - if (!location.pathname.endsWith("/ratings")) return; - - const redirectPath = location.pathname.replace(/\/ratings$/, ""); - navigate(`${redirectPath}${location.search}${location.hash}`, { - replace: true, - }); - }, [dialog, ratingsLocked, location, navigate]); - - const handleLockedTabClick = useCallback(() => { - if (!ratingsLocked) return; - if (!user) { - const redirectPath = window.location.href; - signIn(redirectPath); - return; - } - - const goalCount = - ratingsNeeded <= 0 ? USER_REQUIRED_RATINGS_TO_UNLOCK : ratingsNeeded; - setUnlockModalGoalCount(goalCount); - setIsUnlockModalOpen(true); - setIsUnlockThankYouOpen(false); - }, [ratingsLocked, ratingsNeeded, user]); - - const handleUnlockModalClose = useCallback(() => { - setIsUnlockModalOpen(false); - setUnlockModalGoalCount(0); - setIsUnlockThankYouOpen(false); - }, []); - - const handleUnlockRatingSubmit = useCallback( - async ( - metricValues: MetricData, - termInfo: { semester: Semester; year: number }, - classInfo: { subject: string; courseNumber: string; classNumber: string } - ) => { - const populatedMetrics = METRIC_NAMES.filter( - (metric) => typeof metricValues[metric] === "number" - ); - if (populatedMetrics.length === 0) { - throw new Error(`No populated metrics`); - } - - const missingRequiredMetrics = REQUIRED_METRICS.filter( - (metric) => !populatedMetrics.includes(metric) - ); - if (missingRequiredMetrics.length > 0) { - throw new Error( - `Missing required metrics: ${missingRequiredMetrics.join(", ")}` - ); - } - - // Build metrics array for batch submission - const metrics = populatedMetrics.map((metric) => ({ - metricName: metric, - value: metricValues[metric] as number, - })); - - await createUnlockRatings({ - variables: { - subject: classInfo.subject, - courseNumber: classInfo.courseNumber, - semester: termInfo.semester, - year: termInfo.year, - classNumber: classInfo.classNumber, - metrics, - }, - refetchQueries: [{ query: GetUserRatingsDocument }], - awaitRefetchQueries: true, - }); - }, - [createUnlockRatings] - ); - - const shouldShowUnlockModal = - !!user && - ((ratingsLocked && unlockModalGoalCount > 0) || - isUnlockModalOpen || - isUnlockThankYouOpen); - // seat reservation logic pending design + consideration for performance. // const seatReservationTypeMap = useMemo(() => { // const reservationTypes = @@ -441,7 +241,7 @@ export default function Class({ // const reservationMap = new Map(); // for (const type of reservationTypes) { - // reservationMap.set(type.number, type.requirementGroup.description); + // reservationMap.set(type.number, type.requirementGroup); // } // return reservationMap; // }, [_class]); @@ -460,7 +260,7 @@ export default function Class({ // const seatReservationCount = // _class?.primarySection.enrollment?.latest?.seatReservationCount ?? []; - const courseGradeDistribution = _course?.gradeDistribution; + const courseGradeDistribution = _class?.course.gradeDistribution; const hasCourseGradeSummary = useMemo(() => { if (!courseGradeDistribution) return false; @@ -475,11 +275,29 @@ export default function Class({ return true; } - return false; + return courseGradeDistribution.distribution?.some((grade) => { + const count = grade.count ?? 0; + return count > 0; + }); }, [courseGradeDistribution]); - const activeReservedMaxCount = - _class?.primarySection?.enrollment?.latest?.activeReservedMaxCount ?? 0; + const reservedSeatingMaxCount = useMemo(() => { + const seatReservationCount = + _class?.primarySection?.enrollment?.latest?.seatReservationCount ?? []; + return seatReservationCount.reduce( + (sum, reservation) => sum + (reservation.maxEnroll ?? 0), + 0 + ); + }, [_class]); + + if (loading || courseLoading) { + return ( +
+
+
+
+ ); + } // TODO: Error state if (!_course || !_class) { @@ -487,60 +305,85 @@ export default function Class({ } return ( - <> - - - - + + + + + + + + {/* TODO: Reusable pin button + + (pinned ? removePin(pin) : addPin(pin))} + > + {pinned ? : } + + */} + + + + + + + + {/* TODO: Reusable bookmark button */} + + bookmark()} + disabled={userLoading} + > + {bookmarked ? : } + + + + + + + + + - - -

- {_class.subject} {_class.courseNumber}{" "} - - #{formatClassNumber(_class.number)} - -

-

{classTitle}

-
- - {/* TODO: Reusable bookmark button */} - bookmark()} - disabled={userLoading} - > - {bookmarked ? : } - - } - /> - - - - } - /> - + +

+ {_class.subject} {_class.courseNumber}{" "} + + #{formatClassNumber(_class.number)} + +

+

+ {_class.title || _class.course.title} +

- + {(content) => ( )} @@ -584,18 +422,15 @@ export default function Class({ unitsMax={_class.unitsMax} unitsMin={_class.unitsMin} /> - {primarySection?.sectionId && ( - + {_class && ( + )} - {activeReservedMaxCount > 0 && ( -
- -
+ {reservedSeatingMaxCount > 0 && ( + } + /> )}
@@ -608,18 +443,18 @@ export default function Class({ Sections - {shouldShowRatingsTab && ( - - )} + + + Ratings + {ratingsCount ? ( +
{ratingsCount}
+ ) : ( +
+ )} +
+
Grades @@ -640,17 +475,18 @@ export default function Class({ Sections )} - {shouldShowRatingsTab && ( - - )} + + {({ isActive }) => ( + + Ratings + {ratingsCount ? ( +
{ratingsCount}
+ ) : ( +
+ )} +
+ )} +
{({ isActive }) => ( Grades @@ -663,149 +499,48 @@ export default function Class({
)} -
-
- - {dialog ? ( +
+
+
+ + + {dialog && ( <> - }> + - }> + - }> + - {!ratingsLocked && ( - - }> - - - - )} + + + + + - }> + - ) : ( - <> - {/* Lazy mount: only render tabs that have been visited, keep them mounted */} - {visitedTabs.has("sections") && ( -
- }> - - -
- )} - {visitedTabs.has("grades") && ( -
- }> - - -
- )} - {!ratingsLocked && visitedTabs.has("ratings") && ( -
- }> - - -
- )} - {visitedTabs.has("enrollment") && ( -
- }> - - -
- )} - {visitedTabs.has("overview") && ( -
- }> - - -
- )} - )} -
-
-
- {shouldShowUnlockModal && ( - { - const message = getRatingErrorMessage(error); - setErrorMessage(message); - setIsErrorDialogOpen(true); - }} - /> - )} - setIsUnlockThankYouOpen(false)} - /> - setIsErrorDialogOpen(false)} - errorMessage={errorMessage} - /> - + + + + ); } diff --git a/apps/frontend/src/components/Class/locks.helpers.ts b/apps/frontend/src/components/Class/locks.helpers.ts deleted file mode 100644 index 40153ce1e..000000000 --- a/apps/frontend/src/components/Class/locks.helpers.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { USER_REQUIRED_RATINGS_TO_UNLOCK } from "@repo/shared"; - -export interface RatingsLockContext { - userRatingsCount?: number; - requiredRatingsCount?: number; - requiresLogin?: boolean; -} - -export const shouldDisplayRatingsTab = (context?: RatingsLockContext) => { - void context; - return true; -}; - -export const getRequiredRatingsTarget = (context?: RatingsLockContext) => - context?.requiredRatingsCount ?? USER_REQUIRED_RATINGS_TO_UNLOCK; - -export const getRatingsNeeded = (context?: RatingsLockContext) => { - if (!context) return 0; - if (context.requiresLogin) { - return getRequiredRatingsTarget(context); - } - if (typeof context.userRatingsCount !== "number") return 0; - return Math.max( - 0, - getRequiredRatingsTarget(context) - context.userRatingsCount - ); -}; - -export const isRatingsLocked = (context?: RatingsLockContext) => - context?.requiresLogin ? true : getRatingsNeeded(context) > 0; diff --git a/apps/frontend/src/components/Class/locks.tsx b/apps/frontend/src/components/Class/locks.tsx deleted file mode 100644 index e0adc8e1c..000000000 --- a/apps/frontend/src/components/Class/locks.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { MouseEvent, ReactElement, ReactNode } from "react"; - -import { Lock } from "iconoir-react"; -import { NavLink, NavLinkProps } from "react-router-dom"; - -import { MenuItem, Tooltip } from "@repo/theme"; - -import { - RatingsLockContext, - getRatingsNeeded, - isRatingsLocked, - shouldDisplayRatingsTab, -} from "./locks.helpers"; - -interface RatingsTabClasses { - badge: string; - dot: string; - tooltipArrow: string; - tooltipContent: string; - tooltipTitle: string; - tooltipDescription: string; -} - -interface RatingsTabLinkProps { - to: NavLinkProps["to"]; - dialog?: boolean; - ratingsCount?: number | false; - locked?: boolean; - onLockedClick?: () => void; - loginRequired?: boolean; - ratingsNeededValue?: number; - classes: RatingsTabClasses; -} - -type RatingsTabLinkComponent = (props: RatingsTabLinkProps) => ReactElement; - -interface RatingsTabLinkStatics { - shouldDisplay: (context?: RatingsLockContext) => boolean; - isLocked: (context?: RatingsLockContext) => boolean; - ratingsNeeded: (context?: RatingsLockContext) => number; -} - -type RatingsTabLinkType = RatingsTabLinkComponent & RatingsTabLinkStatics; - -function RatingsTabLinkBase({ - to, - dialog = false, - ratingsCount, - locked = isRatingsLocked(), - onLockedClick, - loginRequired = false, - ratingsNeededValue = 0, - classes, -}: RatingsTabLinkProps) { - const badge = ratingsCount ? ( -
{ratingsCount}
- ) : ( -
- ); - - const handleLockedClick = (event: MouseEvent) => { - if (!locked) return; - event.preventDefault(); - event.stopPropagation(); - onLockedClick?.(); - }; - - const renderMenuItem = (isActive = false): ReactNode => ( - - {locked && } - Ratings - {badge} - - ); - - const navLink = ( - - {dialog ? renderMenuItem() : ({ isActive }) => renderMenuItem(isActive)} - - ); - - if (!locked) { - return navLink; - } - - const tooltipDescription = loginRequired - ? "Log in to view ratings from other students." - : `Rate ${Math.max(ratingsNeededValue, 1)} classes to unlock all other ratings.`; - - return ( - - ); -} - -export const RatingsTabLink: RatingsTabLinkType = Object.assign( - RatingsTabLinkBase, - { - shouldDisplay: shouldDisplayRatingsTab, - isLocked: isRatingsLocked, - ratingsNeeded: getRatingsNeeded, - } -); - -export type { RatingsTabClasses }; diff --git a/apps/frontend/src/components/ClassBrowser/Filters/Filters.module.scss b/apps/frontend/src/components/ClassBrowser/Filters/Filters.module.scss index 48b88f103..617c2c44e 100644 --- a/apps/frontend/src/components/ClassBrowser/Filters/Filters.module.scss +++ b/apps/frontend/src/components/ClassBrowser/Filters/Filters.module.scss @@ -3,6 +3,7 @@ flex-shrink: 0; overflow-y: auto; background-color: var(--foreground-color); + scrollbar-color: var(--label-color) var(--foreground-color); @media (width <= 992px) { width: 384px; @@ -28,7 +29,7 @@ } .clearButton { - font-size: var(--text-14); + font-size: 14px; color: var(--blue-500); line-height: 20px; background: none; @@ -38,7 +39,11 @@ transition: all 100ms ease-in-out; &:hover { - color: var(--blue-hover); + color: var(--blue-600); + + @media (prefers-color-scheme: dark) { + color: var(--blue-400); + } } } diff --git a/apps/frontend/src/components/ClassBrowser/Filters/index.tsx b/apps/frontend/src/components/ClassBrowser/Filters/index.tsx index 9ea2f5be0..58fa9d6e5 100644 --- a/apps/frontend/src/components/ClassBrowser/Filters/index.tsx +++ b/apps/frontend/src/components/ClassBrowser/Filters/index.tsx @@ -4,6 +4,7 @@ import classNames from "classnames"; import { SortDown, SortUp } from "iconoir-react"; import { useNavigate } from "react-router-dom"; +import { subjects } from "@repo/shared"; import { DaySelect, IconButton, Select, Slider } from "@repo/theme"; import type { Option, OptionItem } from "@repo/theme"; @@ -55,8 +56,8 @@ export default function Filters() { updateUniversityRequirement, gradingFilters, updateGradingFilters, - academicOrganization, - updateAcademicOrganization, + department, + updateDepartment, open, // updateOpen, online, @@ -104,7 +105,7 @@ export default function Filters() { breadths, universityRequirement, gradingFilters, - academicOrganization + department ).includedClasses; }, [ allClasses, @@ -117,7 +118,7 @@ export default function Filters() { breadths, universityRequirement, gradingFilters, - academicOrganization, + department, ]); const filteredLevels = useMemo(() => { @@ -141,7 +142,7 @@ export default function Filters() { ); }, [classesForLevelCounts]); - const classesWithoutAcademicOrganization = useMemo( + const classesWithoutDepartment = useMemo( () => getFilteredClasses( allClasses, @@ -168,15 +169,15 @@ export default function Filters() { ] ); - const academicOrganizationCounts = useMemo(() => { + const departmentCounts = useMemo(() => { const counts = new Map(); - classesWithoutAcademicOrganization.forEach((_class) => { - const org = _class.course.academicOrganization; - if (!org) return; - counts.set(org, (counts.get(org) ?? 0) + 1); + classesWithoutDepartment.forEach((_class) => { + const key = _class.subject?.toLowerCase(); + if (!key) return; + counts.set(key, (counts.get(key) ?? 0) + 1); }); return counts; - }, [classesWithoutAcademicOrganization]); + }, [classesWithoutDepartment]); const classesWithoutRequirements = useMemo( () => @@ -190,25 +191,16 @@ export default function Filters() { [], null, gradingFilters, - academicOrganization + department ).includedClasses, - [ - allClasses, - units, - levels, - days, - open, - online, - gradingFilters, - academicOrganization, - ] + [allClasses, units, levels, days, open, online, gradingFilters, department] ); const breadthCounts = useMemo(() => { const counts = new Map(); classesWithoutRequirements.forEach((_class) => { const breadthList = getBreadthRequirements( - _class.primarySection?.sectionAttributes ?? [] + _class.primarySection.sectionAttributes ?? [] ); breadthList.forEach((breadth) => { counts.set(breadth, (counts.get(breadth) ?? 0) + 1); @@ -242,7 +234,7 @@ export default function Filters() { breadths, universityRequirement, [], - academicOrganization + department ).includedClasses, [ allClasses, @@ -253,7 +245,7 @@ export default function Filters() { online, breadths, universityRequirement, - academicOrganization, + department, ] ); @@ -336,93 +328,49 @@ export default function Filters() { })); }, [gradingCounts]); - const academicOrganizationData = useMemo(() => { - const orgMap = new Map }>(); - - classesWithoutAcademicOrganization.forEach((_class) => { - const org = _class.course.academicOrganization; - const orgName = _class.course.academicOrganizationName; - const nicknames = _class.course.departmentNicknames; - - if (!org || !orgName) return; - - const nicknameList = - nicknames - ?.split("!") - .map((n) => n.trim()) - .filter(Boolean) ?? []; - - const existing = orgMap.get(org); - if (!existing) { - orgMap.set(org, { - name: orgName, - nicknames: new Set(nicknameList), - }); - return; - } - - nicknameList.forEach((nickname) => existing.nicknames.add(nickname)); + const departmentOptions = useMemo[]>(() => { + const allSubjects = new Set(); + allClasses.forEach((_class) => { + if (_class.subject) allSubjects.add(_class.subject); }); - return new Map( - Array.from(orgMap.entries()).map(([code, data]) => [ - code, - { - name: data.name, - nicknames: data.nicknames.size ? Array.from(data.nicknames) : null, - }, - ]) - ); - }, [classesWithoutAcademicOrganization]); - - const academicOrganizationOptions = useMemo[]>(() => { - const options = Array.from(academicOrganizationData.entries()).map( - ([code, data]) => ({ - value: code, - label: data.name, - meta: (academicOrganizationCounts.get(code) ?? 0).toString(), - type: "option" as const, - }) + const options = Array.from(allSubjects).reduce[]>( + (acc, code) => { + const key = code.toLowerCase(); + const info = subjects[key]; + if (!info) return acc; + acc.push({ + value: key, + label: info.name, + meta: (departmentCounts.get(key) ?? 0).toString(), + type: "option", + }); + return acc; + }, + [] ); return options.sort((a, b) => a.label.localeCompare(b.label)); - }, [academicOrganizationData, academicOrganizationCounts]); - - const departmentSearchFunction = ( - query: string, - options: Option[] - ) => { - if (!query || query.trim() === "") return options; - - const searchLower = query.toLowerCase(); - return options.filter((opt) => { - if (opt.type === "label") return true; - - const orgData = academicOrganizationData.get(opt.value); - if (!orgData) return false; - - // Search in department name - if (orgData.name.toLowerCase().includes(searchLower)) return true; - - // Search in nicknames - if (orgData.nicknames) { - return orgData.nicknames.some((nickname) => - nickname.toLowerCase().includes(searchLower) - ); - } - - return false; - }); - }; + }, [allClasses, departmentCounts]); // Disable filters when all options have count 0 - const isAcademicOrganizationDisabled = useMemo( + const isDepartmentDisabled = useMemo( () => - academicOrganizationOptions.length === 0 || - academicOrganizationOptions.every((opt) => opt.meta === "0" || !opt.meta), - [academicOrganizationOptions] + departmentOptions.length === 0 || + departmentOptions.every((opt) => opt.meta === "0" || !opt.meta), + [departmentOptions] ); + const isRequirementsDisabled = useMemo(() => { + const optionItems = requirementOptions.filter( + (opt): opt is OptionItem => opt.type !== "label" + ); + return ( + optionItems.length === 0 || + optionItems.every((opt) => opt.meta === "0" || !opt.meta) + ); + }, [requirementOptions]); + const isClassLevelDisabled = useMemo( () => Object.values(filteredLevels).every((count) => count === 0), [filteredLevels] @@ -495,7 +443,7 @@ export default function Filters() { const availableTerms = useMemo(() => { if (!terms) return []; - return [...terms] + return terms .filter( ({ year, semester }, index) => index === @@ -503,7 +451,7 @@ export default function Filters() { (term) => term.semester === semester && term.year === year ) ) - .sort(sortByTermDescending); + .toSorted(sortByTermDescending); }, [terms]); const currentTermLabel = `${semester} ${year}`; @@ -513,7 +461,7 @@ export default function Filters() { updateBreadths([]); updateUniversityRequirement(null); updateGradingFilters([]); - updateAcademicOrganization(null); + updateDepartment(null); updateUnits([0, 5]); setDaysArray([...EMPTY_DAYS]); updateDays([]); @@ -542,7 +490,6 @@ export default function Filters() {

Semester

setIsOpen(true)} + onFocus={() => setIsOpen(true)} + onClick={() => { + if (!searchQuery && selectedCourse && onClear) onClear(); // clear on click + setIsOpen(true); + }} onChange={(e) => { setSearchQuery(e.target.value); if (onClear) onClear(); }} - style={inputStyle} + style={{ + ...inputStyle, + cursor: + !searchQuery && selectedCourse && onClear ? "pointer" : undefined, + }} />
@@ -142,7 +108,7 @@ export default function CourseSearch({ ) : (
- {!minimal && recentCourses.length > 0 && ( + {recentCourses.length > 0 && (

RECENT

@@ -173,46 +139,28 @@ export default function CourseSearch({ )} {currentCourses.length > 0 && ( -
- {!minimal &&

CATALOG

} +
+

CATALOG

- {currentCourses.map((course) => { - const isRated = isCourseRated( - course.subject, - course.number - ); - return ( - - ); - })} + {currentCourses.map((course) => ( + + ))} {!searchQuery && catalogCourses.length > 50 && (
- +{catalogCourses.length - 50} more courses. Search and - narrow results. + +{catalogCourses.length - 50} more courses. Type to + search and narrow results.
)}
diff --git a/apps/frontend/src/components/CourseSelect/index.tsx b/apps/frontend/src/components/CourseSelect/index.tsx deleted file mode 100644 index f98e0f227..000000000 --- a/apps/frontend/src/components/CourseSelect/index.tsx +++ /dev/null @@ -1,249 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; - -import { useQuery } from "@apollo/client/react"; - -import { Select } from "@repo/theme"; -import type { Option } from "@repo/theme"; - -import { ICourse } from "@/lib/api"; -import { GetCourseNamesDocument } from "@/lib/generated/graphql"; -import { Recent, RecentType, getRecents } from "@/lib/recent"; - -import { initialize } from "../CourseSearch/browser"; - -interface CourseSelectProps { - onSelect?: (course: CourseOption) => void; - onClear?: () => void; - selectedCourse: CourseOption | null; - minimal?: boolean; - ratedCourses?: Array<{ subject: string; courseNumber: string }>; - disableRatedCourses?: boolean; - lockedCourse?: CourseOption | null; -} - -export type CourseOption = Pick; - -export default function CourseSelect({ - onSelect, - onClear, - selectedCourse, - minimal = false, - ratedCourses = [], - disableRatedCourses = true, - lockedCourse = null, -}: CourseSelectProps) { - const { data, loading } = useQuery(GetCourseNamesDocument); - - const [recentCourses, setRecentCourses] = useState< - Recent[] - >([]); - const [searchQuery, setSearchQuery] = useState(""); - - // Deduplicate courses: keep course with highest courseId for each subject-number - const catalogCourses = useMemo(() => { - if (!data?.courses) return []; - - const seen = new Map(); - for (const course of data.courses) { - const key = `${course.subject}-${course.number}`; - const existing = seen.get(key); - if (!existing || course.courseId > existing.courseId) { - seen.set(key, course); - } - } - - return Array.from(seen.values()); - }, [data]); - - // Initialize fuzzy search index - const index = useMemo(() => initialize(catalogCourses), [catalogCourses]); - - // Load recent courses on mount - useEffect(() => { - setRecentCourses(getRecents(RecentType.Course)); - }, []); - - // Check if a course is rated - const isCourseRated = (subject: string, number: string) => { - return ratedCourses.some( - (rated) => rated.subject === subject && rated.courseNumber === number - ); - }; - - // Build options dynamically based on search query - const options = useMemo(() => { - if (lockedCourse) { - const lockedLabel = `${lockedCourse.subject} ${lockedCourse.number}`; - const isRated = isCourseRated(lockedCourse.subject, lockedCourse.number); - const lockedCatalogCourse = catalogCourses.find( - (c) => - c.subject === lockedCourse.subject && c.number === lockedCourse.number - ); - const lockedValue = { - subject: lockedCourse.subject, - number: lockedCourse.number, - courseId: lockedCatalogCourse?.courseId ?? lockedCourse.courseId ?? "", - }; - return [ - { - value: lockedValue, - label: lockedLabel, - meta: isRated ? "Rated" : undefined, - disabled: false, - }, - ] satisfies Option[]; - } - - const resolveCourseOptionValue = (course: { - subject: string; - number: string; - courseId?: string | null; - }): CourseOption | null => { - const matchingCourse = catalogCourses.find( - (c) => c.subject === course.subject && c.number === course.number - ); - const courseId = matchingCourse?.courseId ?? course.courseId; - if (!courseId) return null; - - return { - subject: course.subject, - number: course.number, - courseId, - }; - }; - - const courseToOptionValue = (course: { - subject: string; - number: string; - courseId?: string | null; - }): CourseOption => { - const resolved = resolveCourseOptionValue(course); - if (!resolved) { - return { - subject: course.subject, - number: course.number, - courseId: course.courseId ?? "", - }; - } - return resolved; - }; - - const opts: Option[] = []; - - // If there's a search query, use fuzzy search - if (searchQuery && searchQuery.trim() !== "") { - const searchResults = index - .search(searchQuery.slice(0, 24)) - .slice(0, 50) - .map(({ refIndex }) => catalogCourses[refIndex]); - - for (const course of searchResults) { - const isRated = isCourseRated(course.subject, course.number); - const optionValue = courseToOptionValue(course); - opts.push({ - value: optionValue, - label: `${course.subject} ${course.number}`, - meta: isRated ? "Rated" : undefined, - disabled: disableRatedCourses && isRated, - }); - } - - return opts; - } - - // No search: show recent + first 20 catalog courses for performance - if (!minimal && recentCourses.length > 0) { - opts.push({ type: "label", label: "Recent" }); - - for (const recent of recentCourses) { - const full = catalogCourses.find( - (c) => c.subject === recent.subject && c.number === recent.number - ); - if (full) { - const isRated = isCourseRated(full.subject, full.number); - const optionValue = courseToOptionValue(full); - opts.push({ - value: optionValue, - label: `${full.subject} ${full.number}`, - meta: isRated ? "Rated" : undefined, - disabled: disableRatedCourses && isRated, - }); - } - } - } - - // Add catalog courses group (limited to first 20 for performance) - opts.push({ type: "label", label: minimal ? "" : "Catalog" }); - - const limitedCourses = catalogCourses.slice(0, 20); - for (const course of limitedCourses) { - const isRated = isCourseRated(course.subject, course.number); - const optionValue = courseToOptionValue(course); - opts.push({ - value: optionValue, - label: `${course.subject} ${course.number}`, - meta: isRated ? "Rated" : undefined, - disabled: disableRatedCourses && isRated, - }); - } - - return opts; - }, [ - catalogCourses, - recentCourses, - minimal, - ratedCourses, - disableRatedCourses, - searchQuery, - index, - lockedCourse, - ]); - - // Convert selected course to value format - const normalizedSelectedCourse = useMemo(() => { - if (!selectedCourse) return null; - - const matchingCatalogCourse = catalogCourses.find( - (course) => - course.subject === selectedCourse.subject && - course.number === selectedCourse.number - ); - - // Prefer catalog courseId when we only have subject/number - const courseId = matchingCatalogCourse?.courseId ?? selectedCourse.courseId; - if (!courseId) return null; - - return { - subject: selectedCourse.subject, - number: selectedCourse.number, - courseId, - } satisfies CourseOption; - }, [selectedCourse, catalogCourses]); - - const selectedLabel = selectedCourse - ? `${selectedCourse.subject} ${selectedCourse.number}` - : undefined; - - return ( - - searchable - options={options} - value={normalizedSelectedCourse} - selectedLabel={selectedLabel} - onChange={(newValue) => { - if (newValue && !Array.isArray(newValue)) { - onSelect?.(newValue as CourseOption); - } else { - onClear?.(); - } - }} - placeholder="Select a class" - searchPlaceholder="Search classes" - emptyMessage="No courses found." - loading={loading} - disabled={loading} - onSearchChange={lockedCourse ? undefined : setSearchQuery} - clearable={!lockedCourse} - /> - ); -} diff --git a/apps/frontend/src/components/CourseSelectionCard/CourseSelectionCard.module.scss b/apps/frontend/src/components/CourseSelectionCard/CourseSelectionCard.module.scss deleted file mode 100644 index 755298320..000000000 --- a/apps/frontend/src/components/CourseSelectionCard/CourseSelectionCard.module.scss +++ /dev/null @@ -1,83 +0,0 @@ -.card { - min-width: 300px; - max-width: 330px; - flex: 1 1 300px; -} - -.content { - padding: 16px; - border-radius: 8px; - width: 100%; - box-sizing: border-box; -} - -.header { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - min-width: 0; -} - -.headerTitle { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; - flex-shrink: 1; -} - -.courseCode { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - line-height: 1; - display: flex; - align-items: center; - font-size: var(--text-16); - font-weight: var(--font-medium); - color: var(--heading-color); -} - -.headerActions { - display: flex; - align-items: center; - gap: 8px; - flex-shrink: 0; -} - -.icon { - color: var(--label-color); - cursor: pointer; - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - line-height: 1; -} - -.body { - width: 100%; - min-width: 0; - margin-top: 8px; - font-size: var(--text-14); - font-weight: var(--font-normal); - color: var(--paragraph-color); - display: flex; - flex-direction: column; - gap: 12px; -} - -.title { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - width: 100%; -} - -.metadata { - width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} diff --git a/apps/frontend/src/components/CourseSelectionCard/index.tsx b/apps/frontend/src/components/CourseSelectionCard/index.tsx deleted file mode 100644 index 58b97fbf4..000000000 --- a/apps/frontend/src/components/CourseSelectionCard/index.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { useRef } from "react"; - -import { Eye, EyeClosed, Trash } from "iconoir-react"; - -import { Card, ColoredSquare } from "@repo/theme"; - -import { AverageGrade } from "@/components/AverageGrade"; -import { - useReadCourseGradeDist, - useReadCourseTitle, -} from "@/hooks/api/courses/useReadCourse"; -import { IGradeDistribution } from "@/lib/api"; - -import styles from "./CourseSelectionCard.module.scss"; - -interface CourseSelectionCardProps { - color: string; - subject: string; - number: string; - title?: string; - metadata: string; - gradeDistribution?: IGradeDistribution; - loadGradeDistribution?: boolean; - onClick: () => void; - onClickDelete: () => void; - onClickHide: () => void; - active: boolean; - hidden: boolean; -} - -export default function CourseSelectionCard({ - color, - subject, - number, - title, - metadata, - gradeDistribution, - loadGradeDistribution = true, - onClick, - onClickDelete, - onClickHide, - active, - hidden, -}: CourseSelectionCardProps) { - const hideRef = useRef(null); - const deleteRef = useRef(null); - - const { data: titleData } = useReadCourseTitle(subject, number, { - skip: !!title, - }); - const { data: courseGradeData } = useReadCourseGradeDist(subject, number, { - skip: !!gradeDistribution || !loadGradeDistribution, - }); - - const displayTitle = title ?? titleData?.title ?? "N/A"; - const displayGradeDistribution = - gradeDistribution ?? courseGradeData?.gradeDistribution; - - return ( - { - if (hidden) return; - if ( - hideRef.current && - !hideRef.current.contains(event.target as Node) && - deleteRef.current && - !deleteRef.current.contains(event.target as Node) - ) { - onClick(); - } - }} - > -
-
-
- - - {subject} {number} - -
- -
- {displayGradeDistribution && ( - - )} -
- {!hidden ? : } -
-
- -
-
-
- -
-
{displayTitle}
-
{metadata}
-
-
-
- ); -} diff --git a/apps/frontend/src/components/CourseSideMetrics/CourseSideMetrics.module.scss b/apps/frontend/src/components/CourseSideMetrics/CourseSideMetrics.module.scss deleted file mode 100644 index 90d10c3bb..000000000 --- a/apps/frontend/src/components/CourseSideMetrics/CourseSideMetrics.module.scss +++ /dev/null @@ -1,105 +0,0 @@ -.info { - width: 100%; - color: var(--paragraph-color); - display: flex; - flex-direction: column; - gap: 24px; - padding-bottom: 12px; - - @media (max-width: 1000px) { - flex-direction: column; - align-items: flex-start; - justify-content: flex-start; - gap: 0; - } - - .classInfo { - display: flex; - flex-direction: column; - gap: 8px; - - @media (max-width: 1000px) { - display: none; - } - - .heading { - font-weight: var(--font-medium); - - .course { - color: var(--heading-color); - font-size: var(--text-18); - display: inline-flex; - align-items: center; - gap: 8px; - } - } - - .classTitle { - font-size: var(--text-16); - color: var(--paragraph-color); - } - - .metadata { - font-size: var(--text-16); - color: var(--paragraph-color); - } - } - - .compactTitle { - display: none; - - @media (max-width: 1000px) { - display: inline-flex; - align-items: center; - gap: 24px; - margin-bottom: 12px; - flex-wrap: wrap; - - .compactTitleLeft { - display: inline-flex; - align-items: center; - gap: 8px; - } - - .compactCourseTitle { - color: var(--heading-color); - font-size: var(--text-18); - font-weight: var(--font-medium); - } - - .compactMetadata { - font-size: var(--text-16); - color: var(--paragraph-color); - } - } - } - - .label { - font-weight: var(--font-normal); - color: var(--heading-color); - font-size: var(--text-14); - } - - .value { - font-size: var(--text-14); - color: var(--paragraph-color); - } - - .metricGroup { - display: flex; - flex-direction: column; - gap: 12px; - - @media (max-width: 1000px) { - flex-direction: row; - gap: 24px; - align-items: center; - } - } - - .metric { - display: flex; - flex-direction: column; - gap: 4px; - } -} diff --git a/apps/frontend/src/components/CourseSideMetrics/index.tsx b/apps/frontend/src/components/CourseSideMetrics/index.tsx deleted file mode 100644 index 8a627d347..000000000 --- a/apps/frontend/src/components/CourseSideMetrics/index.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { ReactNode } from "react"; - -import { ColoredSquare } from "@repo/theme"; - -import styles from "./CourseSideMetrics.module.scss"; - -export type CourseMetric = { - label: string; - value: ReactNode; -}; - -interface CourseSideMetricsProps { - color: string; - courseTitle: string; - classTitle?: string; - metadata: string; - metrics: CourseMetric[]; -} - -export function CourseSideMetrics({ - color, - courseTitle, - classTitle, - metadata, - metrics, -}: CourseSideMetricsProps) { - return ( -
-
-
- - - {courseTitle} - -
-
- {classTitle ?? "No Class Title Data"} -
-
{metadata}
-
- -
-
- - {courseTitle} -
- {metadata !== "No Semester or Instructor Data" && - metadata !== "No Class Selected" && ( - {metadata} - )} -
- -
- {metrics.map((metric, index) => ( -
-
{metric.label}
-
{metric.value}
-
- ))} -
-
- ); -} - -export default CourseSideMetrics; diff --git a/apps/frontend/src/components/Details/Details.module.scss b/apps/frontend/src/components/Details/Details.module.scss index b218be85c..e7c6074a7 100644 --- a/apps/frontend/src/components/Details/Details.module.scss +++ b/apps/frontend/src/components/Details/Details.module.scss @@ -18,10 +18,10 @@ a { color: var(--blue-500); - font-weight: var(--font-medium); + font-weight: 500; &:hover { - color: var(--blue-hover); + color: var(--blue-600); } } } diff --git a/apps/frontend/src/components/Details/index.tsx b/apps/frontend/src/components/Details/index.tsx index 8c398db7d..e9689106c 100644 --- a/apps/frontend/src/components/Details/index.tsx +++ b/apps/frontend/src/components/Details/index.tsx @@ -67,10 +67,10 @@ export default function Details({ WebkitBoxOrient: "vertical", }} > - {instructors && instructors.length > 0 - ? instructors - .map((i) => `${i.givenName} ${i.familyName}`) - .join(", ") + {instructors?.[0] + ? instructors.length === 1 + ? `${instructors[0].givenName} ${instructors[0].familyName}` + : `${instructors[0].givenName} ${instructors[0].familyName}, et al.` : "To be determined"}

diff --git a/apps/frontend/src/components/EnrollmentDisplay/EnrollmentDisplay.module.scss b/apps/frontend/src/components/EnrollmentDisplay/EnrollmentDisplay.module.scss index 4a94cc983..9b4cb4957 100644 --- a/apps/frontend/src/components/EnrollmentDisplay/EnrollmentDisplay.module.scss +++ b/apps/frontend/src/components/EnrollmentDisplay/EnrollmentDisplay.module.scss @@ -5,3 +5,40 @@ user-select: none; cursor: pointer; } + +.content { + border-radius: 4px; + padding: 12px; + background-color: var(--tooltip-color); + color: var(--description-color); + max-width: 256px; + font-size: var(--text-14); + animation: fadeIn 100ms ease-in; + z-index: 998; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + + .arrow { + fill: var(--tooltip-color); + } + + .title { + font-weight: var(--font-semibold); + color: var(--tooltip-heading-color); + margin-bottom: 8px; + line-height: 1; + } + + .description { + line-height: 1.5; + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + diff --git a/apps/frontend/src/components/EnrollmentDisplay/index.tsx b/apps/frontend/src/components/EnrollmentDisplay/index.tsx index 190be0d63..e17e7c269 100644 --- a/apps/frontend/src/components/EnrollmentDisplay/index.tsx +++ b/apps/frontend/src/components/EnrollmentDisplay/index.tsx @@ -1,8 +1,7 @@ import { ReactNode, useMemo } from "react"; import moment from "moment"; - -import { Tooltip } from "@repo/theme"; +import { Tooltip } from "radix-ui"; import { getEnrollmentColor } from "@/components/Capacity"; @@ -36,23 +35,33 @@ export default function EnrollmentDisplay({ const color = getEnrollmentColor(enrolledCount, maxEnroll); const content = ( - + {percentage}% enrolled - } - title="Enrollment" - description={ - <> - - {enrolledCount}/{maxEnroll} - {" "} - students are enrolled in this class for this semester as of{" "} - {formattedTime} - - } - /> + + + +
+ +

Enrollment

+

+ + {enrolledCount}/{maxEnroll} + {" "} + students are enrolled in this class for this semester as of{" "} + {formattedTime} +

+
+
+
+ ); return children ? children(content) : content; diff --git a/apps/frontend/src/components/Footer/Footer.module.scss b/apps/frontend/src/components/Footer/Footer.module.scss index a9808c7ad..ad4e72e48 100644 --- a/apps/frontend/src/components/Footer/Footer.module.scss +++ b/apps/frontend/src/components/Footer/Footer.module.scss @@ -34,8 +34,8 @@ .link { color: var(--heading-color); transition: all 100ms ease-in-out; - font-weight: var(--font-medium); - font-size: var(--text-16); + font-weight: 500; + font-size: 16px; &:hover { color: var(--paragraph-color); @@ -48,7 +48,7 @@ gap: 16px; .label { - font-size: var(--text-12); + font-size: 12px; color: var(--label-color); } } @@ -63,7 +63,7 @@ } .description { - font-size: var(--text-16); + font-size: 16px; margin-top: 16px; color: var(--paragraph-color); } @@ -77,10 +77,14 @@ "ss07" on, "cv12" on, "cv06" on; - color: var(--heading-color); + color: var(--blue-500); cursor: pointer; transition: all 100ms ease-in-out; margin-right: auto; + + &:hover { + color: var(--blue-600); + } } } } diff --git a/apps/frontend/src/components/Footer/index.tsx b/apps/frontend/src/components/Footer/index.tsx index f880ae3f7..5e88def59 100644 --- a/apps/frontend/src/components/Footer/index.tsx +++ b/apps/frontend/src/components/Footer/index.tsx @@ -1,8 +1,15 @@ import classNames from "classnames"; -import { Discord, Github, Instagram } from "iconoir-react"; +import { + Discord, + Facebook, + Github, + HalfMoon, + Instagram, + SunLight, +} from "iconoir-react"; import { Link } from "react-router-dom"; -import { Box, Container, IconButton } from "@repo/theme"; +import { Box, Container, IconButton, useTheme } from "@repo/theme"; import styles from "./Footer.module.scss"; @@ -11,6 +18,8 @@ interface FooterProps { } export default function Footer({ invert }: FooterProps) { + const { theme, setTheme } = useTheme(); + return ( @@ -35,6 +44,14 @@ export default function Footer({ invert }: FooterProps) { project

+ + setTheme((theme) => (theme === "dark" ? "light" : "dark")) + } + > + {theme === "dark" ? : } + + + + +

You are viewing a beta release of Berkeleytime.

-
+ ); } diff --git a/apps/frontend/src/components/Layout/index.tsx b/apps/frontend/src/components/Layout/index.tsx index 0d2c62aef..77a098ac5 100644 --- a/apps/frontend/src/components/Layout/index.tsx +++ b/apps/frontend/src/components/Layout/index.tsx @@ -6,6 +6,7 @@ import Footer from "@/components/Footer"; import NavigationBar from "@/components/NavigationBar"; import Banner from "./Banner"; +import Feedback from "./Feedback"; import styles from "./Layout.module.scss"; interface LayoutProps { @@ -22,6 +23,7 @@ export default function Layout({ header = true, footer = true }: LayoutProps) { {footer &&