@@ -10,7 +10,13 @@ import {
10
10
import { Filter } from 'react-feather' ;
11
11
import { State as StoreState } from 'types/state' ;
12
12
13
- import { attributeDescription , NUSModuleAttributes , Semester , Semesters } from 'types/modules' ;
13
+ import {
14
+ attributeDescription ,
15
+ Module ,
16
+ NUSModuleAttributes ,
17
+ Semester ,
18
+ Semesters ,
19
+ } from 'types/modules' ;
14
20
import { RefinementItem } from 'types/views' ;
15
21
16
22
import SideMenu , { OPEN_MENU_LABEL } from 'views/components/SideMenu' ;
@@ -27,6 +33,11 @@ import config from 'config';
27
33
import styles from './ModuleFinderSidebar.scss' ;
28
34
import ChecklistFilter , { FilterItem } from '../components/filters/ChecklistFilter' ;
29
35
36
+ type ExamTiming = {
37
+ start : string ;
38
+ duration : number ;
39
+ } ;
40
+
30
41
const RESET_FILTER_OPTIONS = { filter : true } ;
31
42
32
43
const STATIC_EXAM_FILTER_ITEMS : FilterItem [ ] = [
@@ -50,7 +61,28 @@ const STATIC_EXAM_FILTER_ITEMS: FilterItem[] = [
50
61
} ,
51
62
] ;
52
63
53
- function getExamClashFilter ( semester : Semester , examDates : string [ ] ) : FilterItem {
64
+ function getExamClashFilter ( semester : Semester , examTimings : ExamTiming [ ] ) : FilterItem {
65
+ // @param startTime is an ISO string in UTC timezone
66
+ const getEndTime = ( startTime : string , duration : number ) : string => {
67
+ const endTime = new Date ( startTime ) ;
68
+ endTime . setMinutes ( endTime . getMinutes ( ) + duration ) ;
69
+ return endTime . toISOString ( ) ;
70
+ } ;
71
+ // Map each exam to an Elasticsearch range query.
72
+ // Exam2 clashes with exam1 when (exam2.start < exam1.end) && (exam2.end > exam1.start)
73
+ const clashRanges = examTimings . map ( ( exam ) => ( {
74
+ bool : {
75
+ must : {
76
+ range : {
77
+ 'semesterData.examDate' : {
78
+ gte : exam . start , // TODO find a way to subtract semesterData.duration
79
+ lt : getEndTime ( exam . start , exam . duration ) ,
80
+ } ,
81
+ } ,
82
+ } ,
83
+ } ,
84
+ } ) ) ;
85
+
54
86
return {
55
87
key : `no-exam-clash-${ semester } ` ,
56
88
label : `No Exam Clash (${ config . shortSemesterNames [ semester ] } )` ,
@@ -60,8 +92,8 @@ function getExamClashFilter(semester: Semester, examDates: string[]): FilterItem
60
92
nested : {
61
93
path : 'semesterData' ,
62
94
query : {
63
- terms : {
64
- 'semesterData.examDate' : examDates ,
95
+ bool : {
96
+ must_not : clashRanges ,
65
97
} ,
66
98
} ,
67
99
} ,
@@ -83,10 +115,18 @@ const ModuleFinderSidebar: React.FC = () => {
83
115
const examClashFilters = Semesters . map ( ( semester ) : FilterItem | null => {
84
116
const timetable = getSemesterTimetable ( semester ) ;
85
117
const modules = getSemesterModules ( timetable , allModules ) ;
86
- const examDates = modules
87
- . map ( ( module ) => getModuleSemesterData ( module , semester ) ?. examDate )
88
- . filter ( notNull ) ;
89
- return examDates . length ? getExamClashFilter ( semester , examDates ) : null ;
118
+ // Filter for modules with non-empty exam timings, and map them to new ExamTiming objects
119
+ const examTimings = modules . reduce < ExamTiming [ ] > ( ( result : ExamTiming [ ] , mod : Module ) => {
120
+ const data = getModuleSemesterData ( mod , semester ) ;
121
+ if ( data ?. examDate && data ?. examDuration ) {
122
+ result . push ( {
123
+ start : data . examDate ,
124
+ duration : data . examDuration ,
125
+ } )
126
+ }
127
+ return result ;
128
+ } , [ ] ) ;
129
+ return examTimings . length ? getExamClashFilter ( semester , examTimings ) : null ;
90
130
} ) . filter ( notNull ) ;
91
131
return [ ...STATIC_EXAM_FILTER_ITEMS , ...examClashFilters ] ;
92
132
} , [ getSemesterTimetable , allModules ] ) ;
0 commit comments