diff --git a/README.md b/README.md index 1dac5e04..38296586 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,12 @@ # StressLess +[VISIT OUR DEPLOYED PRODUCT](https://stressless-frontend.onrender.com/) + +_Please be patient with the deployed version as we use free tier services to deploy and it takes quite a while for things to load. Thank you!_ + +## Repository +
Table of Contents
    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 new file mode 100644 index 00000000..37c719ff --- /dev/null +++ b/backend/src/controllers/ScheduleController.ts @@ -0,0 +1,94 @@ +import { Request, Response } from 'express'; +import prisma from '../config/prisma.ts'; +import Scheduler from '../utils/Scheduler.ts'; +import { UserPreferences } from '../db/types.ts'; +import UserPreferenceUtils from '../utils/UserPreferenceUtils.ts'; + +/** + * ScheduleController + * + * Functions for handling triggering the backend to create a `perfect` schedule based on + * the user's current preferences, deadlines, and events. Future work might include functions + * that allow deleting all generated events, regenerating schedule, etc. + * + * How to use: + * + * User sends a HTTP request to the URL with the route that calls the below + * function. Check ../routes/user.ts for the exact routes. + * + * For example, if we want to generate a schedule: + * + * 1. From the frontend, send an HTTP POST request to the route which calls + * the generateSchedule function. For example: + * + * POST /api/user/generate-schedule/ + * + * 2. The frontend receives the data in JSON format (JS objects/arrays). + * + * Note that we can use the Axios library in the frontend to send HTTP requests. + */ +export default class ScheduleController { + /** + * generateSchedule is a function that use the user's uuid to fetch + * user_preferences + * user_events + * user_deadlines where projected_duration and due_time are not null + * call functions from utils.Scheduler to get generated events data + * loop through result.scheduledEvents and insert into user_events database + * + * @param req + * should have user's uuid at req.params.user_id + * should not have anything in req.body + * @param res + * send back json format + */ + public static async generateSchedule(req: Request, res: Response) { + try { + // extract out the user's id + const userId = req.params.user_id; + + // fetch user's preferences + const userPreferences = await prisma.user_preferences.findMany({ + where: { + user_id: userId, + }, + }); + // transform to the processed preference type + const processedPreferences : UserPreferences = UserPreferenceUtils.transform(userPreferences); + + // fetch user's events + const userEvents = await prisma.user_events.findMany({ + where: { + user_id: req.params.user_id, + }, + }); + + // fetch user's deadlines + const userDeadlines = await prisma.user_deadlines.findMany({ + where: { + user_id: req.params.user_id, + projected_duration: {not: null}, + due_time: {not: null}, + }, + }); + + // 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: eventDataWithoutId, + }); + } + + } catch (error : any) { + res + .status(500) + .json({ error: 'Error in auto-generating schedule, ' + error.message }); + } + } +} \ No newline at end of file diff --git a/backend/src/routes/calendar.ts b/backend/src/routes/calendar.ts index 608ba707..674e1c7f 100644 --- a/backend/src/routes/calendar.ts +++ b/backend/src/routes/calendar.ts @@ -6,7 +6,7 @@ import DeadlineController from '../controllers/DeadlineController'; const router = Router(); // Get all events for a particular user -router.get('/events/by-user/:user', EventController.getUserEvents); +router.get('/events/by-user/:user_id', EventController.getUserEvents); // Add an event router.post('/events', EventController.postEvent); @@ -21,7 +21,7 @@ router.put('/events/id/:id', EventController.putEvent); router.delete('/events/id/:id', EventController.deleteEvent); // Get all deadlines for a particular user -router.get('/deadlines/by-user/:user', DeadlineController.getUserDeadlines); +router.get('/deadlines/by-user/:user_id', DeadlineController.getUserDeadlines); // Add a deadline router.post('/deadlines', DeadlineController.postDeadline); diff --git a/backend/src/routes/user.ts b/backend/src/routes/user.ts index a9433f98..24fb9d12 100644 --- a/backend/src/routes/user.ts +++ b/backend/src/routes/user.ts @@ -1,17 +1,10 @@ import PreferenceController from '../controllers/PreferenceController'; -import { Request, Response, NextFunction } from 'express'; +import ScheduleController from '../controllers/ScheduleController.ts'; // Creates the router const express = require('express'); const router = express.Router(); -// Keeps track of time on the website -const timeLog = (req: Request, res: Response, next: NextFunction) => { - console.log('Time: ', Date.now()); - next(); -}; -router.use(timeLog); - // Save survey results to database router.post('/surveyresults/:user_id', PreferenceController.postPreferences); // Get all survey results from database @@ -25,4 +18,7 @@ router.put( PreferenceController.putPreferenceByUserId ); +// Generate events according to user's preferences and pre-existing calendar +router.post('/generate-schedule/:user_id', ScheduleController.generateSchedule); + export default router; diff --git a/backend/src/test/userPreferenceUtils.spec.ts b/backend/src/test/userPreferenceUtils.spec.ts new file mode 100644 index 00000000..e001b493 --- /dev/null +++ b/backend/src/test/userPreferenceUtils.spec.ts @@ -0,0 +1,57 @@ +import { UserPreference, UserPreferences } from '../db/types.ts'; +import { faker } from '@faker-js/faker'; +import UserPreferenceUtils from '../utils/UserPreferenceUtils.ts'; + +describe('Test the transform method of user preference utils', () => { + const user_id = faker.string.uuid(); + const ptData : UserPreference = { + answer : "735,1095", + question_id : "pt", + user_id : user_id, + id: faker.string.uuid(), + }; + + const wdData : UserPreference = { + answer : "45", + question_id : "wd", + user_id : user_id, + id: faker.string.uuid(), + }; + + const shData : UserPreference = { + answer : "13", + question_id: "sh", + user_id: user_id, + id: faker.string.uuid(), + }; + + const stData : UserPreference = { + answer: "09:15", + question_id: "st", + user_id: user_id, + id: faker.string.uuid(), + }; + + const etData : UserPreference = { + answer: "18:07", + question_id: "et", + user_id: user_id, + id: faker.string.uuid(), + }; + + const fullUserPreference : UserPreference[] = [ + ptData, wdData, shData, stData, stData, etData + ]; + + const expectedUserPreferences : UserPreferences = { + productiveTime: [735,1095], + workDuration: 45, + sleepHours: 13, + startTime: 555, + endTime: 1087, + }; + + it('should return the correct User Preferences model data', async () => { + expect(UserPreferenceUtils.transform(fullUserPreference)).toEqual(expectedUserPreferences); + }); +}); \ No newline at end of file diff --git a/backend/src/utils/Scheduler.ts b/backend/src/utils/Scheduler.ts index 37b2cd37..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,9 +254,21 @@ export default class Scheduler { private static timeIsFree( events: UserEvent[], userPreferences: UserPreferences, - time: Date = new Date(Date.now()), + time: Date = new Date(Date.now()) ): boolean { - return !(this.eventsAtTime(events, time).length === 0); + // checks whether the current time is within work hours + const currentMinutes = UserPreferenceUtils.dateToMinuteNumber(time); + const isWithinWorkHours = + currentMinutes >= userPreferences.startTime && + currentMinutes <= userPreferences.endTime; + + if (!isWithinWorkHours) return false; + + // checks whether any events overlap at the current time + const hasConflict = this.eventsAtTime(events, time).length > 0; + + // Current time is free when there's no event at this time + return !hasConflict; } /** @@ -105,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) - ) ); } @@ -123,16 +293,33 @@ 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 - // TODO: Convert endTime to only hours and minutes? - events.filter(event => - event && - (event.start_time >= time && UserPreferenceUtils.dateToMinuteNumber(event.start_time) < userPreferences.endTime) - ); - // Then, sort events by start time. - // Calculate minutes to next event, rounded down (to prevent conflicts). - return 0; + const currentMinutes = UserPreferenceUtils.dateToMinuteNumber(time); + const dayEnd = userPreferences.endTime; + const futureEvents = events + .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 dayEnd - currentMinutes; } } \ No newline at end of file diff --git a/backend/src/utils/UserPreferenceUtils.ts b/backend/src/utils/UserPreferenceUtils.ts index 91c418f5..b9019788 100644 --- a/backend/src/utils/UserPreferenceUtils.ts +++ b/backend/src/utils/UserPreferenceUtils.ts @@ -1,9 +1,48 @@ -import { UserPreferences } from '../db/types'; +import { UserPreference, UserPreferences } from '../db/types'; /** * Functions for converting user preference response strings to other types. */ export default class UserPreferenceUtils { + public static transform(object: UserPreference[]): UserPreferences { + const returnedData : UserPreferences = { + productiveTime: [0, 0], + workDuration: 0, + sleepHours: 0, + startTime: 0, + endTime: 0, + }; + + object.forEach((item: UserPreference) => { + const { question_id, answer } = item; + if (!answer) { + console.warn("No answer given to: ", question_id); + return; + } + switch (question_id) { + case 'pt': { + const times = answer.split(',').map((t: string) => parseInt(t, 10)); + returnedData.productiveTime = [times[0], times[1]]; + break; + } + case 'wd': + returnedData.workDuration = parseInt(answer, 10); + break; + case 'sh': + returnedData.sleepHours = parseInt(answer, 10); + break; + case 'st': + returnedData.startTime = UserPreferenceUtils.timeStringToMinuteNumber(answer); + break; + case 'et': + returnedData.endTime = UserPreferenceUtils.timeStringToMinuteNumber(answer); + break; + } + }); + + return returnedData; + } + public static minuteNumberToHour(m: number): number { return Math.floor(m / 60); } @@ -22,4 +61,15 @@ export default class UserPreferenceUtils { public static dateToMinuteNumber(d: Date): number { return d.getHours() * 60 + d.getMinutes(); } + + public static timeStringToMinuteNumber(t: string): number { + const [hourStr, minuteStr] = t.split(':'); + const hour = parseInt(hourStr, 10); + const minute = parseInt(minuteStr, 10); + if (isNaN(hour) || isNaN(minute)) { + console.warn("Invalid time format:", t); + return NaN; + } + return hour * 60 + minute; + } } \ No newline at end of file diff --git a/frontend/src/app/dashboard/calendar/page.tsx b/frontend/src/app/dashboard/calendar/page.tsx index f2b3480d..e18b065c 100644 --- a/frontend/src/app/dashboard/calendar/page.tsx +++ b/frontend/src/app/dashboard/calendar/page.tsx @@ -160,6 +160,16 @@ export default function Home() { title: data.draggedEl.innerText, id: new Date().getTime().toString(), start: data.date, // <-- necessary for FullCalendar + end_time: newEvent.end_time, + break_time: newEvent.break_time, + created_at: newEvent.created_at, + description: newEvent.description, + is_generated: false, + is_recurring: newEvent.is_recurring, + location_place: newEvent.location_place, + recurrence_end_date: newEvent.recurrence_end_date, + recurrence_pattern: newEvent.recurrence_pattern, + recurrence_start_date: newEvent.recurrence_start_date, }; setAllEvents([...allEvents, event]); @@ -215,6 +225,19 @@ export default function Home() { user_id: user.uid, start: new Date(newEvent.start_time), // <-- ensure start is there end: newEvent.end_time ? new Date(newEvent.end_time) : undefined, + title: newEvent.title, + + id: newEvent.id, // UUIDs are strings + break_time: newEvent.break_time, + start_time: newEvent.start_time, + created_at: new Date().toISOString(), + description: newEvent.description, + is_generated: false, + is_recurring: newEvent.is_recurring, + location_place: newEvent.location_place, + recurrence_end_date: newEvent.recurrence_end_date, + recurrence_pattern: newEvent.recurrence_pattern, + recurrence_start_date: newEvent.recurrence_start_date, }; setAllEvents([...allEvents, eventWithUser]); @@ -233,6 +256,14 @@ export default function Home() { }); } + function generatePerfectSchedule() { + console.log('Generate perfect schedule clicked'); + // Add your logic to generate the perfect schedule here + // For example, you can call an API endpoint or perform some calculations + // and then update the state accordingly. + } + + return ( <>