Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
7 changes: 4 additions & 3 deletions backend/src/controllers/ScheduleController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}

Expand Down
248 changes: 207 additions & 41 deletions backend/src/utils/Scheduler.ts
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand All @@ -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 = {
Expand All @@ -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.
Expand All @@ -40,12 +40,124 @@ export default class Scheduler {
deadlines: UserDeadline[],
userPreferences: UserPreferences
): SchedulerOutput {
const scheduledEvents: UserEvent[] = [];
const deadlineRemainders = this.calculateDeadlineRemainders(events, deadlines);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorting by priority may be useful

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh true i forgot to consider that field completely

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will leave this as a to-do since priority is a string

// 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(),
Comment thread
tyu012 marked this conversation as resolved.
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.
Expand All @@ -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;
}

Expand All @@ -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;

Expand All @@ -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)
)
);
}

Expand All @@ -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;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general about this function, this is not filtering by events that occur within the current day first.

Expand Down