From 4389fe3c0b7201c06b79280274cf9d8d58a3d697 Mon Sep 17 00:00:00 2001 From: uyencfi <77076051+uyencfi@users.noreply.github.com> Date: Thu, 21 Mar 2024 00:27:40 +0800 Subject: [PATCH 1/5] Update ES query skeleton --- .../src/views/modules/ModuleFinderSidebar.tsx | 56 ++++++++++++++++--- 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/website/src/views/modules/ModuleFinderSidebar.tsx b/website/src/views/modules/ModuleFinderSidebar.tsx index 4962e6ceac..03df392c8f 100644 --- a/website/src/views/modules/ModuleFinderSidebar.tsx +++ b/website/src/views/modules/ModuleFinderSidebar.tsx @@ -10,7 +10,13 @@ import { import { Filter } from 'react-feather'; import { State as StoreState } from 'types/state'; -import { attributeDescription, NUSModuleAttributes, Semester, Semesters } from 'types/modules'; +import { + attributeDescription, + Module, + NUSModuleAttributes, + Semester, + Semesters, +} from 'types/modules'; import { RefinementItem } from 'types/views'; import SideMenu, { OPEN_MENU_LABEL } from 'views/components/SideMenu'; @@ -27,6 +33,11 @@ import config from 'config'; import styles from './ModuleFinderSidebar.scss'; import ChecklistFilter, { FilterItem } from '../components/filters/ChecklistFilter'; +type ExamTiming = { + start: string; + duration: number; +}; + const RESET_FILTER_OPTIONS = { filter: true }; const STATIC_EXAM_FILTER_ITEMS: FilterItem[] = [ @@ -50,7 +61,28 @@ const STATIC_EXAM_FILTER_ITEMS: FilterItem[] = [ }, ]; -function getExamClashFilter(semester: Semester, examDates: string[]): FilterItem { +function getExamClashFilter(semester: Semester, examTimings: ExamTiming[]): FilterItem { + // @param startTime is an ISO string in UTC timezone + const getEndTime = (startTime: string, duration: number): string => { + const endTime = new Date(startTime); + endTime.setMinutes(endTime.getMinutes() + duration); + return endTime.toISOString(); + }; + // Map each exam to an Elasticsearch range query. + // Exam2 clashes with exam1 when (exam2.start < exam1.end) && (exam2.end > exam1.start) + const clashRanges = examTimings.map((exam) => ({ + bool: { + must: { + range: { + 'semesterData.examDate': { + gte: exam.start, // TODO find a way to subtract semesterData.duration + lt: getEndTime(exam.start, exam.duration), + }, + }, + }, + }, + })); + return { key: `no-exam-clash-${semester}`, label: `No Exam Clash (${config.shortSemesterNames[semester]})`, @@ -60,8 +92,8 @@ function getExamClashFilter(semester: Semester, examDates: string[]): FilterItem nested: { path: 'semesterData', query: { - terms: { - 'semesterData.examDate': examDates, + bool: { + must_not: clashRanges, }, }, }, @@ -83,10 +115,18 @@ const ModuleFinderSidebar: React.FC = () => { const examClashFilters = Semesters.map((semester): FilterItem | null => { const timetable = getSemesterTimetable(semester); const modules = getSemesterModules(timetable, allModules); - const examDates = modules - .map((module) => getModuleSemesterData(module, semester)?.examDate) - .filter(notNull); - return examDates.length ? getExamClashFilter(semester, examDates) : null; + // Filter for modules with non-empty exam timings, and map them to new ExamTiming objects + const examTimings = modules.reduce((result: ExamTiming[], mod: Module) => { + const data = getModuleSemesterData(mod, semester); + if (data?.examDate && data?.examDuration) { + result.push({ + start: data.examDate, + duration: data.examDuration, + }) + } + return result; + }, []); + return examTimings.length ? getExamClashFilter(semester, examTimings) : null; }).filter(notNull); return [...STATIC_EXAM_FILTER_ITEMS, ...examClashFilters]; }, [getSemesterTimetable, allModules]); From 2f4be4f87d614c3c701d3ca885820d27a425753a Mon Sep 17 00:00:00 2001 From: uyencfi <77076051+uyencfi@users.noreply.github.com> Date: Thu, 21 Mar 2024 02:45:32 +0800 Subject: [PATCH 2/5] Update ES query to check overlap --- .../src/views/modules/ModuleFinderSidebar.tsx | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/website/src/views/modules/ModuleFinderSidebar.tsx b/website/src/views/modules/ModuleFinderSidebar.tsx index 03df392c8f..f8b34a4aee 100644 --- a/website/src/views/modules/ModuleFinderSidebar.tsx +++ b/website/src/views/modules/ModuleFinderSidebar.tsx @@ -68,15 +68,25 @@ function getExamClashFilter(semester: Semester, examTimings: ExamTiming[]): Filt endTime.setMinutes(endTime.getMinutes() + duration); return endTime.toISOString(); }; - // Map each exam to an Elasticsearch range query. - // Exam2 clashes with exam1 when (exam2.start < exam1.end) && (exam2.end > exam1.start) - const clashRanges = examTimings.map((exam) => ({ + // For each exam1, map it to an Elasticsearch query that will return True + // if another exam clashes with exam1. For example, exam2 clashes (i.e. overlaps) + // with exam1 iff (exam2.start < exam1.end) && (exam2.end > exam1.start) + const clashRanges = examTimings.map((exam1) => ({ bool: { must: { range: { 'semesterData.examDate': { - gte: exam.start, // TODO find a way to subtract semesterData.duration - lt: getEndTime(exam.start, exam.duration), + lt: getEndTime(exam1.start, exam1.duration), + }, + }, + script: { + script: { + source: `doc.containsKey['semesterData.examDate'] && + doc.containsKey['semesterData.examDuration'] && + ZonedDateTime.parse(doc['semesterData.examDate'].value).plusMinutes(doc['semesterData.examDuration].value).isAfter(ZonedDateTime.parse(params.exam1start))`, + params: { + exam1start: exam1.start, + }, }, }, }, From d6cb27ba6afaff872daf3eef52c66fc18236f031 Mon Sep 17 00:00:00 2001 From: uyencfi <77076051+uyencfi@users.noreply.github.com> Date: Thu, 21 Mar 2024 04:30:42 +0800 Subject: [PATCH 3/5] Fix linting issue --- website/src/views/modules/ModuleFinderSidebar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/views/modules/ModuleFinderSidebar.tsx b/website/src/views/modules/ModuleFinderSidebar.tsx index f8b34a4aee..97f5dda930 100644 --- a/website/src/views/modules/ModuleFinderSidebar.tsx +++ b/website/src/views/modules/ModuleFinderSidebar.tsx @@ -125,14 +125,14 @@ const ModuleFinderSidebar: React.FC = () => { const examClashFilters = Semesters.map((semester): FilterItem | null => { const timetable = getSemesterTimetable(semester); const modules = getSemesterModules(timetable, allModules); - // Filter for modules with non-empty exam timings, and map them to new ExamTiming objects + // Filter for modules with non-empty exam timings, and map them to new ExamTiming objects const examTimings = modules.reduce((result: ExamTiming[], mod: Module) => { const data = getModuleSemesterData(mod, semester); if (data?.examDate && data?.examDuration) { result.push({ start: data.examDate, duration: data.examDuration, - }) + }); } return result; }, []); From 954ed9d45b289f6914a151dec49cf7ae50aa8786 Mon Sep 17 00:00:00 2001 From: uyencfi <77076051+uyencfi@users.noreply.github.com> Date: Wed, 3 Apr 2024 17:06:33 +0800 Subject: [PATCH 4/5] Fix ES query --- .../src/views/modules/ModuleFinderSidebar.tsx | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/website/src/views/modules/ModuleFinderSidebar.tsx b/website/src/views/modules/ModuleFinderSidebar.tsx index 97f5dda930..bff516012f 100644 --- a/website/src/views/modules/ModuleFinderSidebar.tsx +++ b/website/src/views/modules/ModuleFinderSidebar.tsx @@ -73,23 +73,27 @@ function getExamClashFilter(semester: Semester, examTimings: ExamTiming[]): Filt // with exam1 iff (exam2.start < exam1.end) && (exam2.end > exam1.start) const clashRanges = examTimings.map((exam1) => ({ bool: { - must: { - range: { - 'semesterData.examDate': { - lt: getEndTime(exam1.start, exam1.duration), + must: [ + { + range: { + 'semesterData.examDate': { + lt: getEndTime(exam1.start, exam1.duration), + }, }, }, - script: { + { script: { - source: `doc.containsKey['semesterData.examDate'] && - doc.containsKey['semesterData.examDuration'] && - ZonedDateTime.parse(doc['semesterData.examDate'].value).plusMinutes(doc['semesterData.examDuration].value).isAfter(ZonedDateTime.parse(params.exam1start))`, - params: { - exam1start: exam1.start, + script: { + source: `doc.containsKey('semesterData.examDate') && + doc.containsKey('semesterData.examDuration') && + doc['semesterData.examDate'].value.plusMinutes(doc['semesterData.examDuration'].value).isAfter(ZonedDateTime.parse(params.exam1start))`, + params: { + exam1start: exam1.start, + }, }, }, }, - }, + ], }, })); From f38f64f4a8c9295bfd825ff6c73db5984bc5d98f Mon Sep 17 00:00:00 2001 From: Zhao Wei Liew Date: Mon, 18 Nov 2024 18:48:53 +0800 Subject: [PATCH 5/5] Simplify code --- .../src/views/modules/ModuleFinderSidebar.tsx | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/website/src/views/modules/ModuleFinderSidebar.tsx b/website/src/views/modules/ModuleFinderSidebar.tsx index bff516012f..0954cef9ea 100644 --- a/website/src/views/modules/ModuleFinderSidebar.tsx +++ b/website/src/views/modules/ModuleFinderSidebar.tsx @@ -84,8 +84,8 @@ function getExamClashFilter(semester: Semester, examTimings: ExamTiming[]): Filt { script: { script: { - source: `doc.containsKey('semesterData.examDate') && - doc.containsKey('semesterData.examDuration') && + source: `doc.containsKey('semesterData.examDate') && + doc.containsKey('semesterData.examDuration') && doc['semesterData.examDate'].value.plusMinutes(doc['semesterData.examDuration'].value).isAfter(ZonedDateTime.parse(params.exam1start))`, params: { exam1start: exam1.start, @@ -129,17 +129,19 @@ const ModuleFinderSidebar: React.FC = () => { const examClashFilters = Semesters.map((semester): FilterItem | null => { const timetable = getSemesterTimetable(semester); const modules = getSemesterModules(timetable, allModules); - // Filter for modules with non-empty exam timings, and map them to new ExamTiming objects - const examTimings = modules.reduce((result: ExamTiming[], mod: Module) => { - const data = getModuleSemesterData(mod, semester); - if (data?.examDate && data?.examDuration) { - result.push({ - start: data.examDate, - duration: data.examDuration, - }); - } - return result; - }, []); + const examTimings: ExamTiming[] = modules + .map((mod) => { + // Filter for modules with non-empty exam timings, and map them to new ExamTiming objects + const data = getModuleSemesterData(mod, semester); + if (data?.examDate && data?.examDuration) { + return { + start: data.examDate, + duration: data.examDuration, + }; + } + return null; + }) + .filter(notNull); return examTimings.length ? getExamClashFilter(semester, examTimings) : null; }).filter(notNull); return [...STATIC_EXAM_FILTER_ITEMS, ...examClashFilters];