diff --git a/src/components/Requirements/RequirementFulfillment.vue b/src/components/Requirements/RequirementFulfillment.vue index 7f1173921..c4e7b1bb3 100644 --- a/src/components/Requirements/RequirementFulfillment.vue +++ b/src/components/Requirements/RequirementFulfillment.vue @@ -29,6 +29,7 @@ :isCompleted="isCompleted" :displayDescription="displayDescription" :toggleableRequirementChoice="toggleableRequirementChoice" + :requirement="requirementFulfillment.requirement" @onShowAllCourses="onShowAllCourses" /> , eligibleCourseIds: readonly number[], @@ -77,6 +86,7 @@ export default defineComponent({ isCompleted: { type: Boolean, required: true }, displayDescription: { type: Boolean, required: true }, toggleableRequirementChoice: { type: String, default: null }, + requirement: { type: Object as PropType, required: true }, }, emits: { onShowAllCourses(courses: { @@ -174,10 +184,69 @@ export default defineComponent({ }); } - return slots; + // The following code block is to sort the courses in each slot by the ranking of the courses. + // The ranking is stored in the Vuex store. The ranking is an array of course IDs, sorted by + // the number of students who have taken the course. The course with the most students who + // have taken the course is ranked first, and the course with the least students who have + // taken the course is ranked last. + // The ranking is computed by the script '/script/gen-req-full-stats.ts'. + + // Sort the incomplete slots by ranking + const sortedIncompleteReq: SubReqCourseSlot[] = []; + // Get the ranking from the Vuex store for the requirement ID + const ranking = store.state.requirementRanking.get( + this.requirement.id.replace('[FORWARD_SLASH]', '/') + ); + // If the ranking exists, sort the courses in each slot by the ranking + if (ranking) { + // Sort the slots incomplete slots by ranking, and push them to the sortedIncompleteReq + // NOTE: The completed slots are not sorted by ranking because they are already completed + // We must use a for loop instead of a foreach loop because order matters + // slots in rankings are in specific order + for (let k = 0; k < slots.length; k += 1) { + const slot = slots[k]; + + // If the slot is completed, push it to the sortedIncompleteReq + if (slot.isCompleted) { + sortedIncompleteReq.push(slot); + } else { + // Sort the courses in the slot by ranking + const courses = [...slot.courses]; + const sortedCourses = courses.sort((a, b) => { + let aRank = 0; + let bRank = 0; + // Find the ranking of the course + for (let i = 0; i < ranking.length; i += 1) { + if (a.crseId === ranking[i]) { + aRank = i; + } + if (b.crseId === ranking[i]) { + bRank = i; + } + } + // sorted by who has the higher rank + return aRank - bRank; + }); + // Push the sorted slot to the sortedIncompleteReq + sortedIncompleteReq.push({ + name: slot.name, + isCompleted: false, + courses: sortedCourses, + }); + } + } + } else { + // ranking data not yet computed. Need to run '/script/gen-req-full-stats.ts' + } + + return sortedIncompleteReq; // return the sorted slots }, }, methods: { + /** + * Emit an event to show all courses for a requirement. + * @param subReqIndex The index of the sub-requirement. + */ onShowAllCourses(subReqIndex: number) { this.$emit('onShowAllCourses', { requirementName: this.requirementFulfillment.requirement.name, diff --git a/src/firebase-config.ts b/src/firebase-config.ts index 5d83e972f..01954a9ac 100644 --- a/src/firebase-config.ts +++ b/src/firebase-config.ts @@ -89,3 +89,5 @@ export const onboardingDataCollection = collection(db, 'user-onboarding-data').w export const trackUsersCollection = collection(db, 'track-users').withConverter( getTypedFirestoreDataConverter() ); + +export const courseFulfillmentCollection = collection(db, 'course-fulfillment-stats'); diff --git a/src/store.ts b/src/store.ts index 75ba96e6c..aff10dc0b 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,5 +1,5 @@ import { Store } from 'vuex'; -import { doc, getDoc, onSnapshot, setDoc, updateDoc } from 'firebase/firestore'; +import { doc, getDoc, getDocs, onSnapshot, setDoc, updateDoc } from 'firebase/firestore'; import * as fb from './firebase-config'; import computeGroupedRequirementFulfillmentReports from './requirements/requirement-frontend-computation'; @@ -50,6 +50,7 @@ export type VuexStoreState = { subjectColors: Readonly>; uniqueIncrementer: number; isTeleportModalOpen: boolean; + requirementRanking: ReadonlyMap; }; export class TypedVuexStore extends Store {} @@ -96,9 +97,21 @@ const store: TypedVuexStore = new TypedVuexStore({ subjectColors: {}, uniqueIncrementer: 0, isTeleportModalOpen: false, + requirementRanking: new Map(), }, actions: {}, mutations: { + /** + * Sets the requirementRanking in the store. + * @param state The Vuex store state. + * @param requirementRanking The requirementRanking to set. + */ + setRequirementRanking( + state: VuexStoreState, + requirementRanking: ReadonlyMap + ) { + state.requirementRanking = requirementRanking; + }, setCurrentFirebaseUser(state: VuexStoreState, user: SimplifiedFirebaseUser) { state.currentFirebaseUser = user; }, @@ -224,6 +237,11 @@ const autoRecomputeDerivedData = (): (() => void) => } }); +/** + * Initializes the Firestore listeners for the current user. + * @param onLoad Callback to be called when all Firestore listeners are loaded. + * @returns A function to unsubscribe all Firestore listeners. + */ export const initializeFirestoreListeners = (onLoad: () => void): (() => void) => { const simplifiedUser = store.state.currentFirebaseUser; @@ -354,6 +372,24 @@ export const initializeFirestoreListeners = (onLoad: () => void): (() => void) = uniqueIncrementerUnsubscriber(); derivedDataComputationUnsubscriber(); }; + + // Populate the Vuex store with requirementRankgs + getDocs(fb.courseFulfillmentCollection).then(snapshot => { + const requirementRanking: Map = new Map(); + for (let i = 0; i < snapshot.docs.length; i += 1) { + const { id, data } = snapshot.docs[i]; + + // For each requirement, create a ranking of courses + const ranking: number[] = []; + for (let j = 0; j < Object.entries(data).length; j += 1) { + const crseId = Object.entries(data)[j][1]; // crseId is the course ID + ranking.push(crseId); // push the course ID into the ranking + } + + requirementRanking.set(id, ranking); // set the ranking for the requirement + } + store.commit('setRequirementRanking', requirementRanking); // set the requirementRanking in the store + }); return unsubscriber; };