website/src/utils/timetables.ts
import { AcadWeekInfo } from 'nusmoderator';
import {
castArray,
difference,
each,
first,
flatMapDeep,
groupBy,
invert,
isEmpty,
isEqual,
last,
map,
mapValues,
partition,
pick,
range,
sample,
values,
} from 'lodash';
import { addDays, min as minDate, parseISO, startOfDay } from 'date-fns';
import qs from 'query-string';
import {
ClassNo,
consumeWeeks,
LessonType,
Module,
ModuleCode,
NumericWeeks,
RawLesson,
Semester,
} from 'types/modules';
import {
ColoredLesson,
HoverLesson,
Lesson,
ModuleLessonConfig,
SemTimetableConfig,
SemTimetableConfigWithLessons,
TimetableArrangement,
TimetableDayArrangement,
TimetableDayFormat,
} from 'types/timetables';
import { ModuleCodeMap, ModulesMap } from 'types/reducers';
import { ExamClashes } from 'types/views';
import { getTimeAsDate } from './timify';
import { getModuleTimetable, getExamDate, getExamDuration } from './modules';
import { deltas } from './array';
type lessonTypeAbbrev = { [lessonType: string]: string };
export const LESSON_TYPE_ABBREV: lessonTypeAbbrev = {
'Design Lecture': 'DLEC',
Laboratory: 'LAB',
Lecture: 'LEC',
'Packaged Laboratory': 'PLAB',
'Packaged Lecture': 'PLEC',
'Packaged Tutorial': 'PTUT',
Recitation: 'REC',
'Sectional Teaching': 'SEC',
'Seminar-Style Module Class': 'SEM',
Tutorial: 'TUT',
'Tutorial Type 2': 'TUT2',
'Tutorial Type 3': 'TUT3',
Workshop: 'WS',
};
// Reverse lookup map of LESSON_TYPE_ABBREV
export const LESSON_ABBREV_TYPE: { [key: string]: LessonType } = invert(LESSON_TYPE_ABBREV);
// Used for module config serialization - these must be query string safe
// See: https://stackoverflow.com/a/31300627
export const LESSON_TYPE_SEP = ':';
export const LESSON_SEP = ',';
const EMPTY_OBJECT = {};
export function isValidSemester(semester: Semester): boolean {
return semester >= 1 && semester <= 4;
}
// Returns a random configuration of a module's timetable lessons.
// Used when a module is first added.
// TODO: Suggest a configuration that does not clash with itself.
// {
// [lessonType: string]: ClassNo,
// }
export function randomModuleLessonConfig(lessons: readonly RawLesson[]): ModuleLessonConfig {
const lessonByGroups: { [lessonType: string]: readonly RawLesson[] } = groupBy(
lessons,
(lesson) => lesson.lessonType,
);
const lessonByGroupsByClassNo: {
[lessonType: string]: { [classNo: string]: readonly RawLesson[] };
} = mapValues(lessonByGroups, (lessonsOfSamelessonType: readonly RawLesson[]) =>
groupBy(lessonsOfSamelessonType, (lesson) => lesson.classNo),
);
return mapValues(
lessonByGroupsByClassNo,
(group: { [classNo: string]: readonly RawLesson[] }) =>
(first(sample(group)) as RawLesson).classNo,
);
}
// Replaces ClassNo in SemTimetableConfig with Array<Lesson>
export function hydrateSemTimetableWithLessons(
semTimetableConfig: SemTimetableConfig,
modules: ModulesMap,
semester: Semester,
): SemTimetableConfigWithLessons {
return mapValues(
semTimetableConfig,
(moduleLessonConfig: ModuleLessonConfig, moduleCode: ModuleCode) => {
const module: Module = modules[moduleCode];
if (!module) return EMPTY_OBJECT;
// TODO: Split this part into a smaller function: hydrateModuleConfigWithLessons.
return mapValues(moduleLessonConfig, (classNo: ClassNo, lessonType: LessonType) => {
const lessons = getModuleTimetable(module, semester);
const newLessons = lessons.filter(
(lesson: RawLesson): boolean =>
lesson.lessonType === lessonType && lesson.classNo === classNo,
);
const timetableLessons: Lesson[] = newLessons.map(
(lesson: RawLesson): Lesson => ({
...lesson,
moduleCode,
title: module.title,
}),
);
return timetableLessons;
});
},
);
}
// Filters a flat array of lessons and returns the lessons corresponding to lessonType.
export function lessonsForLessonType<T extends RawLesson>(
lessons: readonly T[],
lessonType: LessonType,
): readonly T[] {
return lessons.filter((lesson) => lesson.lessonType === lessonType);
}
// Converts from timetable config format to flat array of lessons.
// {
// [moduleCode: string]: {
// [lessonType: string]: [Lesson, Lesson, ...],
// [lessonType: string]: [Lesson, ...],
// }
// }
export function timetableLessonsArray(timetable: SemTimetableConfigWithLessons): Lesson[] {
return flatMapDeep(timetable, values);
}
// Groups flat array of lessons by day.
// {
// Monday: [Lesson, Lesson, ...],
// Tuesday: [Lesson, ...],
// }
export function groupLessonsByDay(lessons: ColoredLesson[]): TimetableDayFormat {
return groupBy(lessons, (lesson) => lesson.day);
}
// Determines if two lessons overlap:
export function doLessonsOverlap(lesson1: Lesson, lesson2: Lesson): boolean {
return (
lesson1.day === lesson2.day &&
lesson1.startTime < lesson2.endTime &&
lesson2.startTime < lesson1.endTime
);
}
// Converts a flat array of lessons *for ONE day* into rows of lessons within that day row.
// Result invariants:
// - Each lesson will not overlap with each other.
// [
// [Lesson, Lesson, ...],
// [Lesson, ...],
// ]
export function arrangeLessonsWithinDay(lessons: ColoredLesson[]): TimetableDayArrangement {
const rows: TimetableDayArrangement = [[]];
if (isEmpty(lessons)) {
return rows;
}
const sortedLessons = lessons.sort((a, b) => {
const timeDiff = a.startTime.localeCompare(b.startTime);
return timeDiff !== 0 ? timeDiff : a.classNo.localeCompare(b.classNo);
});
sortedLessons.forEach((lesson: ColoredLesson) => {
for (let i = 0; i < rows.length; i++) {
const rowLessons: ColoredLesson[] = rows[i];
const previousLesson = last(rowLessons);
if (!previousLesson || !doLessonsOverlap(previousLesson, lesson)) {
// Lesson does not overlap with any Lesson in the row. Add it to row.
rowLessons.push(lesson);
return;
}
}
// No existing rows are available to fit this lesson in. Append a new row.
rows.push([lesson]);
});
return rows;
}
// Accepts a flat array of lessons and groups them by day and rows with each day
// for rendering on the timetable.
// Clashes in Array<Lesson> will go onto the next row within that day.
// {
// Monday: [
// [Lesson, Lesson, ...],
// ],
// Tuesday: [
// [Lesson, Lesson, Lesson, ...],
// [Lesson, Lesson, ...],
// [Lesson, ...],
// ],
// ...
// }
export function arrangeLessonsForWeek(lessons: ColoredLesson[]): TimetableArrangement {
const dayLessons = groupLessonsByDay(lessons);
return mapValues(dayLessons, (dayLesson: ColoredLesson[]) => arrangeLessonsWithinDay(dayLesson));
}
// Determines if a Lesson on the timetable can be modifiable / dragged around.
// Condition: There are multiple ClassNo for all the Array<Lesson> in a lessonType.
export function areOtherClassesAvailable(
lessons: readonly RawLesson[],
lessonType: LessonType,
): boolean {
const lessonTypeGroups = groupBy<RawLesson>(lessons, (lesson) => lesson.lessonType);
if (!lessonTypeGroups[lessonType]) {
// No such lessonType.
return false;
}
return Object.keys(groupBy(lessonTypeGroups[lessonType], (lesson) => lesson.classNo)).length > 1;
}
// Creates a key using only the exam date string (without time)
export function getExamDateOnly(module: Module, semester: Semester): string | undefined {
const examDateTime = getExamDate(module, semester);
return examDateTime?.slice(0, 10);
}
// Returns the start time of the exam as an epoch time (number). Throws an error if the module
// does not have an exam date.
export function getValidExamStartTimeAsEpoch(module: Module, semester: Semester): number {
const startTimeString = getExamDate(module, semester);
if (startTimeString === null) {
throw new Error('Courses tested for clashes must have exam dates and durations!');
}
return new Date(startTimeString).getTime();
}
// Returns the end time of the exam as an epoch time (number). Throws an error if the module
// does not have an exam date or duration.
export function getValidExamEndTimeAsEpoch(module: Module, semester: Semester): number {
const duration = getExamDuration(module, semester);
if (duration === null) {
throw new Error('Courses tested for clashes must have exam dates and durations!');
}
const startEpoch = getValidExamStartTimeAsEpoch(module, semester);
return startEpoch + duration * 60 * 1000;
}
// Find all exam clashes between modules in semester
// Returns object associating exam dates with the modules clashing on those dates
export function findExamClashes(modules: Module[], semester: Semester): ExamClashes {
// Filter away modules without exam dates or exam durations
const filteredModules = modules.filter(
(module) =>
getExamDate(module, semester) !== null && getExamDuration(module, semester) !== null,
);
const groupedModules = groupBy(filteredModules, (module) => getExamDateOnly(module, semester));
const clashes: ExamClashes = {};
Object.values(groupedModules).forEach((sameDayMods) => {
// Sort sameDayMods by exam start time
sameDayMods.sort((a, b) => {
const aStartEpoch = getValidExamStartTimeAsEpoch(a, semester);
const bStartEpoch = getValidExamStartTimeAsEpoch(b, semester);
// Use end time as secondary key
const aEndEpoch = getValidExamEndTimeAsEpoch(a, semester);
const bEndEpoch = getValidExamEndTimeAsEpoch(b, semester);
if (aStartEpoch === bStartEpoch) {
return aEndEpoch - bEndEpoch;
}
return aStartEpoch - bStartEpoch;
});
// Initialize an empty list to hold the groups of overlapping intervals
// Each group will itself be a list of intervals
const overlappingGroups: Module[][] = [];
let currentOverlapEnd = 0;
let currentOverlappingMods: Module[] = [];
sameDayMods.forEach((mod, modIndex) => {
if (modIndex > 0 && getValidExamStartTimeAsEpoch(mod, semester) < currentOverlapEnd) {
currentOverlappingMods.push(mod);
} else {
// The current course does not overlap with the current group, so we reset
// the current group and start a new one
if (currentOverlappingMods.length > 1) {
// If the current group has more than one module, we add it to the list of clashes
overlappingGroups.push(currentOverlappingMods);
}
currentOverlapEnd = getValidExamEndTimeAsEpoch(mod, semester);
currentOverlappingMods = [mod];
}
});
// Add the last group to the list of clashes if applicable
if (currentOverlappingMods.length > 1) {
overlappingGroups.push(currentOverlappingMods);
}
overlappingGroups.forEach((group) => {
// Displayed clashing date and time, which is the start time of the last module in the group
const clashingDateTime = getExamDate(group[group.length - 1], semester);
if (clashingDateTime === null) {
throw new Error('Courses tested for clashes must have exam dates and durations!');
}
// Populate the clashes object to be returned
group.forEach((mod) => {
if (!clashes[clashingDateTime]) {
clashes[clashingDateTime] = [mod];
} else {
clashes[clashingDateTime].push(mod);
}
});
});
});
return clashes;
}
export function isLessonAvailable(
lesson: Lesson,
date: Date,
weekInfo: Readonly<AcadWeekInfo>,
): boolean {
return consumeWeeks(
lesson.weeks,
(weeks) => weeks.includes(weekInfo.num as number),
(weekRange) => {
const end = minDate([parseISO(weekRange.end), date]);
for (let current = parseISO(weekRange.start); current <= end; current = addDays(current, 7)) {
if (isEqual(current, startOfDay(date))) return true;
}
return false;
},
);
}
export function isLessonOngoing(lesson: Lesson, currentTime: number): boolean {
return (
parseInt(lesson.startTime, 10) <= currentTime && currentTime < parseInt(lesson.endTime, 10)
);
}
export function getStartTimeAsDate(lesson: Lesson, date: Date = new Date()): Date {
return getTimeAsDate(lesson.startTime, date);
}
export function getEndTimeAsDate(lesson: Lesson, date: Date = new Date()): Date {
return getTimeAsDate(lesson.endTime, date);
}
/**
* Validates the modules in a timetable. It removes all modules which do not exist in
* the provided module code map from the timetable and returns that as the first item
* in the tuple, and the module code of all removed modules as the second item.
*
* @param timetable
* @param moduleCodes
* @returns {[SemTimetableConfig, ModuleCode[]]}
*/
export function validateTimetableModules(
timetable: SemTimetableConfig,
moduleCodes: ModuleCodeMap,
): [SemTimetableConfig, ModuleCode[]] {
const [valid, invalid] = partition(
Object.keys(timetable),
(moduleCode: ModuleCode) => moduleCodes[moduleCode],
);
return [pick(timetable, valid), invalid];
}
/**
* Validates the lesson config for a specific module. It replaces all lessons
* which invalid class number with the first available class numbers, and
* removes lessons that are no longer valid
* @param semester
* @param lessonConfig
* @param module
*/
export function validateModuleLessons(
semester: Semester,
lessonConfig: ModuleLessonConfig,
module: Module,
): [ModuleLessonConfig, LessonType[]] {
const validatedLessonConfig: ModuleLessonConfig = {};
const updatedLessonTypes: string[] = [];
const validLessons = getModuleTimetable(module, semester);
const lessonsByType = groupBy(validLessons, (lesson) => lesson.lessonType);
each(lessonsByType, (lessons: RawLesson[], lessonType: LessonType) => {
const classNo = lessonConfig[lessonType];
// Check that the lesson exists and is valid. If it is not, insert a random
// valid lesson. This covers both
//
// - lesson type is not in the original timetable (ie. a new lesson type was introduced)
// in which case classNo is undefined and thus would not match
// - classNo is not valid anymore (ie. the class was removed)
//
// If a lesson type is removed, then it simply won't be copied over
if (!lessons.some((lesson) => lesson.classNo === classNo)) {
validatedLessonConfig[lessonType] = lessons[0].classNo;
updatedLessonTypes.push(lessonType);
} else {
validatedLessonConfig[lessonType] = classNo;
}
});
// Add all of the removed lesson types to the array of updated lesson types
updatedLessonTypes.push(...difference(Object.keys(lessonConfig), Object.keys(lessonsByType)));
return [validatedLessonConfig, updatedLessonTypes];
}
// Get information for all modules present in a semester timetable config
export function getSemesterModules(
timetable: { [moduleCode: string]: unknown },
modules: ModulesMap,
): Module[] {
return values(pick(modules, Object.keys(timetable)));
}
function serializeModuleConfig(config: ModuleLessonConfig): string {
// eg. { Lecture: 1, Laboratory: 2 } => LEC=1,LAB=2
return map(config, (classNo, lessonType) =>
[LESSON_TYPE_ABBREV[lessonType], encodeURIComponent(classNo)].join(LESSON_TYPE_SEP),
).join(LESSON_SEP);
}
function parseModuleConfig(serialized: string | string[] | null): ModuleLessonConfig {
const config: ModuleLessonConfig = {};
if (!serialized) return config;
castArray(serialized).forEach((serializedModule) => {
serializedModule.split(LESSON_SEP).forEach((lesson) => {
const [lessonTypeAbbr, classNo] = lesson.split(LESSON_TYPE_SEP);
const lessonType = LESSON_ABBREV_TYPE[lessonTypeAbbr];
// Ignore unparsable/invalid keys
if (!lessonType) return;
config[lessonType] = classNo;
});
});
return config;
}
/**
* Formats numeric week number array into something human readable
*
* - 1 => Week 1
* - 1,2 => Weeks 1,2
* - 1,2,3 => Weeks 1-3
* - 1,2,3,5,6,7 => Weeks 1-3, 5-7
*/
export function formatNumericWeeks(unprocessedWeeks: NumericWeeks): string | null {
// Ensure list of weeks are unique
const weeks = unprocessedWeeks.filter(
(value, index) => unprocessedWeeks.indexOf(value) === index,
);
if (weeks.length === 13) return null;
if (weeks.length === 1) return `Week ${weeks[0]}`;
// Check for odd / even weeks. There are more odd weeks then even weeks, so we have to split
// the length check.
if (deltas(weeks).every((d) => d === 2)) {
if (weeks[0] % 2 === 0 && weeks.length >= 6) return 'Even Weeks';
if (weeks[0] % 2 === 1 && weeks.length >= 7) return 'Odd Weeks';
}
// Merge consecutive
const processed: (number | string)[] = [];
let start = weeks[0];
let end = start;
const mergeConsecutive = () => {
if (end - start > 2) {
processed.push(`${start}-${end}`);
} else {
processed.push(...range(start, end + 1));
}
};
weeks.slice(1).forEach((next) => {
if (next - end <= 1) {
// Consecutive week number - keep going
end = next;
} else {
// Break = push the current chunk into processed
mergeConsecutive();
start = next;
end = start;
}
});
mergeConsecutive();
return `Weeks ${processed.join(', ')}`;
}
// Converts a timetable config to query string
// eg:
// {
// CS2104: { Lecture: '1', Tutorial: '2' },
// CS2107: { Lecture: '1', Tutorial: '8' },
// }
// => CS2104=LEC:1,Tut:2&CS2107=LEC:1,Tut:8
export function serializeTimetable(timetable: SemTimetableConfig): string {
// We are using query string safe characters, so this encoding is unnecessary
return qs.stringify(mapValues(timetable, serializeModuleConfig), { encode: false });
}
export function deserializeTimetable(serialized: string): SemTimetableConfig {
const params = qs.parse(serialized);
return mapValues(params, parseModuleConfig);
}
export function deserializeHidden(serialized: string): ModuleCode[] {
const params = qs.parse(serialized);
if (!params.hidden) return [];
// If user manually enters multiple hidden query keys, use latest one
const hidden = Array.isArray(params.hidden) ? last(params.hidden) : params.hidden;
return (hidden as string).split(',');
}
export function isSameTimetableConfig(t1: SemTimetableConfig, t2: SemTimetableConfig): boolean {
return isEqual(t1, t2);
}
export function isSameLesson(l1: Lesson, l2: Lesson) {
return (
l1.lessonType === l2.lessonType &&
l1.classNo === l2.classNo &&
l1.moduleCode === l2.moduleCode &&
l1.startTime === l2.startTime &&
l1.endTime === l2.endTime &&
l1.day === l2.day &&
isEqual(l1.weeks, l2.weeks)
);
}
export function getHoverLesson(lesson: Lesson): HoverLesson {
return {
classNo: lesson.classNo,
moduleCode: lesson.moduleCode,
lessonType: lesson.lessonType,
};
}
/**
* Obtain a semi-unique key for a lesson
*/
export function getLessonIdentifier(lesson: Lesson): string {
return `${lesson.moduleCode}-${LESSON_TYPE_ABBREV[lesson.lessonType]}-${lesson.classNo}`;
}