diff --git a/backend/package.json b/backend/package.json index 79ef1dc4..45d0f68e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,6 +20,7 @@ "@types/jest": "^29.5.14", "@types/node": "^22.13.4", "@types/supertest": "^6.0.3", + "crypto": "^1.0.1", "supertest": "^7.1.0", "vitest": "^3.0.9" }, diff --git a/backend/src/controllers/ScheduleController.ts b/backend/src/controllers/ScheduleController.ts index 411f943f..37c719ff 100644 --- a/backend/src/controllers/ScheduleController.ts +++ b/backend/src/controllers/ScheduleController.ts @@ -73,14 +73,15 @@ export default class ScheduleController { }); // feed information to our scheduler logic + // TODO: what to do with unscheduledDeadlines const {scheduledEvents} = Scheduler.scheduleDeadlines(userEvents, userDeadlines, processedPreferences); // loop through the returned events and add them to the database for (const event of scheduledEvents) { + // not passing id in prisma create so that prisma can handle generating uuid + const {id, ...eventDataWithoutId} = event; await prisma.user_events.create({ - data: { - ...event - }, + data: eventDataWithoutId, }); } diff --git a/backend/src/utils/Scheduler.ts b/backend/src/utils/Scheduler.ts index f6a3f842..e3e008bd 100644 --- a/backend/src/utils/Scheduler.ts +++ b/backend/src/utils/Scheduler.ts @@ -1,6 +1,6 @@ import UserPreferenceUtils from "./UserPreferenceUtils"; import { UserDeadline, UserEvent, UserPreferences } from '../db/types.ts'; - +import * as crypto from 'node:crypto'; /** * An object containing a deadline object as well as the number of unscheduled minutes. */ @@ -17,7 +17,7 @@ type DeadlineRemainder = { * `unscheduledDeadlines` is an array of deadlines that are not fully scheduled. * `unscheduledDeadlines` is an array contain the number of hours that remain unscheduled, * in addition to all fields of a deadline object. - * + * * TODO: Determine if `unscheduledDeadlines` contains deadlines that are fully scheduled */ type SchedulerOutput = { @@ -29,7 +29,7 @@ export default class Scheduler { /** * Creates a schedule for all deadlines for a single user by returning the * events scheduled so that the user can complete all deadlines without conflict. - * + * * @param events An array of all event objects for a user. * @param deadlines An array of all deadline objects for a user. * @param userPreferences An object containing user preferences. @@ -40,12 +40,124 @@ export default class Scheduler { deadlines: UserDeadline[], userPreferences: UserPreferences ): SchedulerOutput { + const scheduledEvents: UserEvent[] = []; + const deadlineRemainders = this.calculateDeadlineRemainders(events, deadlines); + // TODO: sort deadlines by priority + + for (const remainder of deadlineRemainders) { + const {deadline, unscheduledMinutes} = remainder; + let minutesLeft = unscheduledMinutes; + + // skip fully scheduled events + if (minutesLeft <= 0) { + continue; + } + + let currentSearchedTime = new Date(); + findTimesLoop: while (minutesLeft > 0) { + + let freeBlock: { time: Date, minutes: number } | null; + + // Search for the day with next free time block + do { + // Get the next time block + freeBlock = this.nextFreeTimeBlock([...events, ...scheduledEvents], userPreferences, currentSearchedTime); + + // when free block not found, find next day + if (!freeBlock) { + // move current time to start of next time block + const workTimeStart = UserPreferenceUtils.minuteNumberToHourAndMinute(userPreferences.startTime); + currentSearchedTime.setDate(currentSearchedTime.getDate() + 1); + currentSearchedTime.setHours(workTimeStart[0]); + currentSearchedTime.setMinutes(workTimeStart[1]); + + // if free block search occurs after deadline, stop searching + if (deadline.due_time != null && currentSearchedTime > deadline.due_time) { + // break out of the while loop called `findTimesLoop` + break findTimesLoop; + } + } + + // if free block ends after deadline + if ( + freeBlock && + deadline.due_time != null && + new Date(freeBlock.time.getTime() + freeBlock.minutes * 60000) > deadline.due_time + ) { + // if free block starts after deadline, stop searching + if (freeBlock.time > deadline.due_time) { + break findTimesLoop; + } else { + // if free block starts before deadline and ends after deadline, + // adjust length of free block so it ends before deadline. + freeBlock.minutes = Math.floor((deadline.due_time.getTime() - freeBlock.time.getTime()) / 60000); + } + } + } while (!freeBlock); + + const scheduleMinutes = Math.min(freeBlock.minutes, minutesLeft); + + // Create a new event + const newEvent : UserEvent = { + id: crypto.randomUUID().toString(), + user_id: deadline.user_id, + title: `Work on ${deadline.title}`, + start_time: freeBlock.time, + end_time: new Date(freeBlock.time.getTime() + scheduleMinutes * 60000), + deadline_id: deadline.id, + description: null, + created_at: new Date(), + location_place: null, + is_recurring: false, + is_generated: true, + recurrence_pattern: '', + recurrence_end_date: null, + recurrence_start_date: null, + break_time: null, + }; + + scheduledEvents.push(newEvent); + minutesLeft -= scheduleMinutes; + + // Move search forward + // TODO: end_time might be null + currentSearchedTime = new Date(newEvent.end_time!.getTime() + 60000); // +1 min buffer + } + // Update unscheduledMinutes in remainder + remainder.unscheduledMinutes = minutesLeft; + } + + // Only return deadlines that still need scheduling + const unscheduledDeadlines = deadlineRemainders.filter(d => d.unscheduledMinutes > 0); + return { - scheduledEvents: [], - unscheduledDeadlines: [], + scheduledEvents, + unscheduledDeadlines, }; } + /** + * Helper method that search an array of events, return an array of event that match deadlineId + * and has an end_time + * @param events An array of all event objects for a user. + * @param deadlineId A deadline_id to match to event + * @private + */ + private static getEventsByDeadlineId(events: UserEvent[], deadlineId: string): UserEvent[] { + let res : UserEvent[] = []; + for (const event of events) { + if (!event.end_time) { + console.warn("This event does not have end time, eventId: " + event.id); + continue; + } + + if (event.deadline_id === deadlineId) { + res.push(event); + } + } + return res; + } + /** * Calculates the remaining hours left for each deadline. * @param events An array of all event objects for a user. @@ -54,38 +166,85 @@ export default class Scheduler { */ static calculateDeadlineRemainders( events: UserEvent[], - deadlines: UserDeadline[], + deadlines: UserDeadline[] ): DeadlineRemainder[] { - return []; + let res : DeadlineRemainder[] = []; + + // process each deadline and find its remaining unscheduled minutes + for (const deadline of deadlines) { + if (!deadline.projected_duration) { + console.warn("No projected duration for deadline: ", deadline.id); + continue; + } + const deadlineId = deadline.id; + let totalEventsDurationForADeadline = 0; + + // search for events with the deadline_id + const eventsFound = Scheduler.getEventsByDeadlineId(events, deadlineId); + + // for each event found, make sure it has an end_time + // then calculate the duration and add it to total duration + for (const event of eventsFound) { + if (!event.end_time) { + console.warn("This event does not have end time, eventId: " + event.id); + continue; + } + // Calculate elapsed time in milliseconds + const timeElapsedMs = event.end_time.getTime() - event.start_time.getTime(); + // Convert from ms to minute + const timeElapsedMin = timeElapsedMs / 60000; + + totalEventsDurationForADeadline += timeElapsedMin; + } + + // calculate minutes left for a deadline and add it to the result + const unscheduledMinutes = deadline.projected_duration - totalEventsDurationForADeadline; + res.push({ + deadline:deadline, + unscheduledMinutes:unscheduledMinutes, + }); + } + return res; } /** * Returns the next free time block where a user is available for working. * Search bounded by end of work day. - * + * * @param events An array of all event objects for a user. * @param userPreferences The user preferences. * @param startSearchTime The start time for searching the next free time block. * @returns an object containing the time and date for the next free time block; - * Returns + * Returns */ static nextFreeTimeBlock( events: UserEvent[], userPreferences: UserPreferences, - startSearchTime: Date = new Date(Date.now()), - ): { time: Date, minutes: number } | null { - // Determine if current time is free - const startTimeIsFree = this.timeIsFree(events, userPreferences, startSearchTime); - if (startTimeIsFree) { - // If start time is free, then check how soon until next event. - const availableMinutes = this.minutesToNextEvent(events, userPreferences, startSearchTime); - return { - time: startSearchTime, - minutes: availableMinutes < userPreferences.workDuration ? availableMinutes : userPreferences.workDuration, - }; - } else { - // Otherwise, check when is the next time block. + startSearchTime: Date = new Date(Date.now()) + ): { time: Date; minutes: number } | null { + + const dayEnd = userPreferences.endTime; + const minIncrement = 5; + + for ( + let checkTime = new Date(startSearchTime); + UserPreferenceUtils.dateToMinuteNumber(checkTime) <= dayEnd; + checkTime = new Date(checkTime.getTime() + minIncrement * 60000) + ) { + if (this.timeIsFree(events, userPreferences, startSearchTime)) { + const availableMinutes = this.minutesToNextEvent( + events, + userPreferences, + checkTime + ); + return { + time: checkTime, + minutes: Math.min(availableMinutes, userPreferences.workDuration), + }; + } + } + return null; } @@ -95,14 +254,13 @@ export default class Scheduler { private static timeIsFree( events: UserEvent[], userPreferences: UserPreferences, - time: Date = new Date(Date.now()), + time: Date = new Date(Date.now()) ): boolean { // checks whether the current time is within work hours const currentMinutes = UserPreferenceUtils.dateToMinuteNumber(time); - const isWithinWorkHours = ( + const isWithinWorkHours = currentMinutes >= userPreferences.startTime && - currentMinutes + userPreferences.workDuration <= userPreferences.endTime - ); + currentMinutes <= userPreferences.endTime; if (!isWithinWorkHours) return false; @@ -118,14 +276,13 @@ export default class Scheduler { */ private static eventsAtTime( events: UserEvent[], - time: Date = new Date(Date.now()), + time: Date = new Date(Date.now()) ): UserEvent[] { - return events.filter(event => - event && - ( - (event.start_time == undefined || event.end_time == undefined) || + return events.filter( + (event) => + event && + (event.start_time != undefined && event.end_time != undefined) && (event.start_time <= time && event.end_time > time) - ) ); } @@ -136,22 +293,31 @@ export default class Scheduler { private static minutesToNextEvent( events: UserEvent[], userPreferences: UserPreferences, - time: Date = new Date(Date.now()), + time: Date = new Date(Date.now()) ): number { // Filter events that start after given time and before end of work day const currentMinutes = UserPreferenceUtils.dateToMinuteNumber(time); const dayEnd = userPreferences.endTime; const futureEvents = events - .filter(event => - event && - (event.start_time >= time && - UserPreferenceUtils.dateToMinuteNumber(event.start_time) < userPreferences.endTime)) - .sort((a, b) => ( - UserPreferenceUtils.dateToMinuteNumber(a.start_time) - - UserPreferenceUtils.dateToMinuteNumber(b.start_time))); + .filter( + (event) => + event && + event.start_time >= time && + event.start_time < new Date(time.getDate() + 1) && + UserPreferenceUtils.dateToMinuteNumber(event.start_time) < + userPreferences.endTime + ) + .sort( + (a, b) => + UserPreferenceUtils.dateToMinuteNumber(a.start_time) - + UserPreferenceUtils.dateToMinuteNumber(b.start_time) + ); if (futureEvents.length > 0) { - return UserPreferenceUtils.dateToMinuteNumber(futureEvents[0].start_time) - currentMinutes; + return ( + UserPreferenceUtils.dateToMinuteNumber(futureEvents[0].start_time) - + currentMinutes + ); } return dayEnd - currentMinutes;