diff --git a/backend/src/controllers/EventController.ts b/backend/src/controllers/EventController.ts index 6634795d..f47acd1d 100644 --- a/backend/src/controllers/EventController.ts +++ b/backend/src/controllers/EventController.ts @@ -63,16 +63,22 @@ export default class EventController { } /** - * Adds a new event to database using values in the request. + * Adds a new event and its recurrences to database using values in the request. + * Response will be an object containing an array. + * * @param req.body - Fields have the same names of the columns in the * `user_events` table. `id` will be ignored since an UUID will be generated. */ public static async postEvent(req: Request, res: Response) { const values = EventController.getUserEventValues(req); - const result = await prisma.user_events.create({ - data: { ...values }, + const eventSeries = EventController.createEventSeriesFromEvent({ ...values }); + // const eventSeries = [{ ...values }]; + const result = await prisma.user_events.createManyAndReturn({ + data: eventSeries, + }); + res.json({ + eventSeries: result, }); - res.json(result); } /** @@ -180,4 +186,128 @@ export default class EventController { deadline_id, }; } + + /** + * Creates an event series from a single event in request if the event is recurring. + * + * Note that we create recurring events in the following way: + * - Create the first event, regardless of whether it is in the recurrence period. + * - Repeatedly check same time previous (day/week/month) to see if it falls + * in recurrence period + * - If time is not in recurrence period but after start of recurrence period, + * do not create an event. + * - If time is in recurrence period, create an event + * - If time if before start of recurrence period, stop searching + * - Repeatedly check same time next (day/week/month) to see if it falls + * in recurrence period + * - Similar logic, but we are moving towards the end of recurrence period + * and stopping search there. + * - Note that if any newly created events due to recurrence (but not the + * event given as the parameter) would fall partially within the recurrence + * period, we do not create this new event. For recurrences to be created, + * they must fall completely within the recurrence period. + * - Note that this also prevents mishandling of recurrence starting after + * its end. + * + * For example: If we want to create an weekly recurring event on May 5, 2025, + * at 00:00-01:00, with the recurrence period between May 12 at 00:30 and + * May 27 at 00:00, then the output would be three events on: + * - May 5, 2025, + * - May 19, 2025, + * - May 26, 2025. + * + * @param event The initial event that we want to create recurrences for. + * This event will always be returned. + * + * @returns An array with `event` and its recurring instances. + */ + private static createEventSeriesFromEvent(event: any) { + const returned = [event]; + + // If event is not recurring or invalid, return single event array + if ( + !event.is_recurring || + !event.recurrence_pattern || + !event.recurrence_start_date || + !event.recurrence_end_date + ) { + return returned; + } + + // Define some helper functions + + const getNextRecurrence = ( + current: Date | null, pattern: string, forward: boolean + ): Date | null => { + if (!current) return null; + + const nextDate = new Date(current.getTime()); + if (pattern === 'daily') { + nextDate.setDate(current.getDate() + (forward ? 1 : -1)); + } else if (pattern === 'weekly') { + nextDate.setDate(current.getDate() + (forward ? 7 : -7)); + } else if (pattern === 'monthly') { + nextDate.setMonth(current.getMonth() + (forward ? 1 : -1)); + } else { + return null; + } + + return nextDate; + }; + + const eventInRecurrencePeriod = ( + eventToCheck: any, recurrenceStart: Date, recurrenceEnd: Date + ): boolean => { + return ( + new Date(eventToCheck.start_time) >= recurrenceStart && ( + !eventToCheck.end_time || + new Date(eventToCheck.end_time) < recurrenceEnd + ) + ); + }; + + const addEventIfInRecurrencePeriod = ( + originalEvent: any, startTime: Date, eventDuration: number, returnedArray: any[] + ) => { + const newEvent = { + ...originalEvent, + start_time: startTime, + end_time: eventDuration > 0 ? new Date(startTime.getTime() + eventDuration) : undefined, + }; + if (eventInRecurrencePeriod( + newEvent, + new Date(event.recurrence_start_date), + new Date(event.recurrence_end_date) + )) { + returnedArray.push(newEvent); + } + }; + + // Calculate event duration + const eventDuration = event.end_time ? + new Date(event.end_time).getTime() - new Date(event.start_time).getTime() : + -1; + + // 1. Add days after event: + for ( + let d = getNextRecurrence(new Date(event.start_time), event.recurrence_pattern, true); + d && d < new Date(event.recurrence_end_date); + d = getNextRecurrence(d, event.recurrence_pattern, true) + ) { + addEventIfInRecurrencePeriod(event, d, eventDuration, returned); + } + + // 2. Add days before event: + for ( + let d = getNextRecurrence(new Date(event.start_time), event.recurrence_pattern, false); + d && d > new Date(event.recurrence_start_date); + d = getNextRecurrence(d, event.recurrence_pattern, false) + ) { + addEventIfInRecurrencePeriod(event, d, eventDuration, returned); + } + + return returned; + } + + } diff --git a/backend/src/utils/Scheduler.ts b/backend/src/utils/Scheduler.ts index 33884ffe..04ef5054 100644 --- a/backend/src/utils/Scheduler.ts +++ b/backend/src/utils/Scheduler.ts @@ -65,7 +65,7 @@ export default class Scheduler { // when free block not found, find next day if (!freeBlock) { - // move current time to start of next time block + // move current time to start of next day const workTimeStart = UserPreferenceUtils.minuteNumberToHourAndMinute(userPreferences.startTime); currentSearchedTime.setDate(currentSearchedTime.getDate() + 1); currentSearchedTime.setHours(workTimeStart[0]); @@ -216,7 +216,6 @@ export default class Scheduler { * @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 */ static nextFreeTimeBlock( events: UserEvent[], @@ -227,12 +226,14 @@ export default class Scheduler { const dayEnd = userPreferences.endTime; const minIncrement = 5; + // Start at startSearchTime and continue search until end of day. + // Increment time by minIncrement minutes if time is not free. for ( let checkTime = new Date(startSearchTime); UserPreferenceUtils.dateToMinuteNumber(checkTime) <= dayEnd; checkTime = new Date(checkTime.getTime() + minIncrement * 60000) ) { - if (this.timeIsFree(events, userPreferences, startSearchTime)) { + if (this.timeIsFree(events, userPreferences, checkTime)) { const availableMinutes = this.minutesToNextEvent( events, userPreferences, @@ -266,7 +267,8 @@ export default class Scheduler { if (!isWithinWorkHours) return false; // checks whether any events overlap at the current time - const hasConflict = this.eventsAtTime(events, time).length > 0; + const conflictingEvents = this.eventsAtTime(events, time); + const hasConflict = conflictingEvents.length > 0; // Current time is free when there's no event at this time return !hasConflict; @@ -282,7 +284,7 @@ export default class Scheduler { return events.filter( (event) => event && - (event.start_time != undefined && event.end_time != undefined) && + (event.start_time && event.end_time) && (event.start_time <= time && event.end_time > time) ); } @@ -299,14 +301,15 @@ export default class Scheduler { // 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 + const minutesToDayEnd = dayEnd - currentMinutes; + const dayEndDate = new Date(time.getTime() + minutesToDayEnd * 60000); + + const futureEventsOnThisDay = events .filter( (event) => event && event.start_time >= time && - event.start_time < new Date(time.getDate() + 1) && - UserPreferenceUtils.dateToMinuteNumber(event.start_time) < - userPreferences.endTime + event.start_time < dayEndDate ) .sort( (a, b) => @@ -314,13 +317,14 @@ export default class Scheduler { UserPreferenceUtils.dateToMinuteNumber(b.start_time) ); - if (futureEvents.length > 0) { - return ( - UserPreferenceUtils.dateToMinuteNumber(futureEvents[0].start_time) - - currentMinutes + if (futureEventsOnThisDay.length > 0) { + const returned = Math.max( + UserPreferenceUtils.dateToMinuteNumber(futureEventsOnThisDay[0].start_time) - currentMinutes - 1, + 1 ); + return returned; } - return dayEnd - currentMinutes; + return minutesToDayEnd; } } \ No newline at end of file diff --git a/frontend/public/help.md b/frontend/public/help.md index 9796fdde..d1091d61 100644 --- a/frontend/public/help.md +++ b/frontend/public/help.md @@ -17,6 +17,12 @@ Welcome to StressLess! Here's how you get started... 3. Click the sign in button above the sign up page to sign in 4. Enter your email and password to sign in +# What if I'm Already Signed In? +1. If you're already signed in, click get started +2. The button will bring you down the page where it says you are already signed in +3. Click on the green go to dashboard button underneath the you already signed in message to access your profile page + + # How Do I Change Visibility Settings? 1. You can view the app in either dark or light mode @@ -69,6 +75,16 @@ You can either edit your preferences in two ways +# Custom Scheduling +## How Do I Create My Custom Schedule? +1. Make sure all of your user preferences are filed out in the profile page (See *How Do I Edit My Preferences in the Survey* on the user help guide on the profile page) +2. Add deadlines (See *How Do I Add My Own Custom Deadline* on the user help guide on the calendar page) +3. Locate the purple generate schedule button in the bottom right corner +4. Press the purple generate schedule button +## What if I Don't Like the Schedule the Custom Schedule Created? +1. Locate the red remove generated events buttons in the bottom right corner underneath the purple generate schedule button +2. Press remove generated events to remove the events the schedule generated + # Calendar ## How Do I Change Calendar Views? @@ -89,19 +105,65 @@ You can either edit your preferences in two ways 1. At the top of the calendar in the left hand corner next to the arrows click on the today button 2. When you click on the today button, the current day will glow yellow showing you what the current day is +## What is The Difference Between an Event and Deadline? +### Event +An **event** is an activity in the schedule that must happen at a specific time. + +Some examples of events include +- Reoccurring class times +- Reoccurring work shifts +- Club meetings +- Sports practices +- A social event +- Scheduled meetings + +### Deadline + +A **deadline** has a due date of when it has to get done, but it has no set time the user has to work on it + +Some example of deadlines include + +- An assignment for class +- An essay to write +- A test to study for +- A presentation to work on +- A project + ## How Do I Add a Preexisting Event? 1. You can add an event when you are on the calendar page. 2. You can drag any of the preexisting events from the right side into the desired day -## How Do I Add My Own Custom Event? +## How Do I Add My Own Custom Event or Deadline? 1. Don’t use the pre-existing events on the right -2. Instead, click on a day you want to add the event -3. Type in the label of your custom event -4. Click create to add your event +2. Instead, click on a day you want to add the event or deadline +3. When you click on the day, the add event or deadline window should open +4. At the top of the file, the user can select whether they want it to be an event or deadline (see the user FAQ *What is the Difference Between an Event and Deadline* to determine if it is classified as an event or deadline) +5. For both events and deadlines, enter in the title in the title box and the description in the description box + +### Events +1. Enter in the location in the location box +2. Enter in the start date and time and end date in time below the location box + +**For Reoccurring Events** + +1. Enter the start date and time and end date and time again. +2. Reoccurring events have the options of daily, weekly, and monthly +3. Select daily if it reoccurs daily +4. Select weekly if it reoccurs weekly +5. Select monthly if it reoccurs monthly +6. Click on the create button to create the event +7. If you don’t want to create the event anymore, press cancel + +### Deadlines +1. Enter in how long you estimate your deadline will take in the projected duration box (it is better to overestimate than underestimate how long it will take) +2. Under the projected duration box, enter what date and what time your deadline is due +3. Click on the create button to create the deadline +4. If you don’t want to create the deadline anymore, press cancel + -## How Do I Change the Day of an Event? +## How Do I Change the Day of an Event or Deadline? 1. You can be in either weekly or monthly mode 2. Change the day by dragging the event to the new desired day @@ -129,11 +191,11 @@ You can either edit your preferences in two ways 5. Increase the number of days for the event by dragging the arrow to the right 6. Decrease the number of days for the event by dragging the arrow to the left -## How Do I Delete an Event? +## How Do I Delete an Event or Deadline? -1. You can delete an event by clicking on the event on the calendar. -2. If you click on the event, you can press the read delete button to delete the event -3. If you don’t want to delete the event, you can press the cancel button +1. You can delete an event or deadline by clicking on the event on the calendar. +2. If you click on the event or deadline, you can press the read delete button to delete the event or deadline +3. If you don’t want to delete the event or deadline, you can press the cancel button ## How Do I Sign Out? 1. Click on the sidebar icon on the upper lefthand corner on either the user profile or calendar page diff --git a/frontend/src/app/dashboard/calendar/page.tsx b/frontend/src/app/dashboard/calendar/page.tsx index b2f4880e..aef66f6b 100644 --- a/frontend/src/app/dashboard/calendar/page.tsx +++ b/frontend/src/app/dashboard/calendar/page.tsx @@ -239,7 +239,7 @@ export default function Home() { recurrence_start_date: newEvent.recurrence_start_date, color: colorTypes.eventByUser, }; - setAllEvents([...allEvents, event]); + // setAllEvents([...allEvents, event]); //added to test getting event name to the backend axios @@ -247,6 +247,13 @@ export default function Home() { .then((response) => { console.log('Successfully saved event into backend: ', user!.uid); console.log(response); + + // Noticed we also need to handle the ID change after adding a dragged + // event. + const correctId = response.data.eventSeries[0].id; + const { id, ...restEvent } = event; + console.log(`Replace ${id} with ${correctId}`); + setAllEvents([...allEvents, { id: correctId, ...restEvent }]); }) .catch((error) => { console.log(error); @@ -346,12 +353,32 @@ export default function Home() { axios .post(backendBaseUrl + `/api/calendar/events`, eventWithUser) .then((response) => { - console.log('Successfully saved event:', response.data); + // controller may save multiple events now. + console.log('Successfully saved events:', response); + console.log('event series', response.data.eventSeries); + const eventsToAdd: (typeof eventWithUser)[] = []; + + // const { id, start_time, end_time, ...restEvent } = eventWithUser; + + for (const newEvent of response.data.eventSeries) { + // Notice that the keys might not be in the correct order + eventsToAdd.push({ + ...eventWithUser, + id: newEvent.id, + start_time: new Date(newEvent.start_time).toISOString(), + end_time: newEvent.endTime, + start: new Date(newEvent.start_time), + end: newEvent.end_time ? new Date(newEvent.end_time) : undefined, + }); + } + + setAllEvents([...allEvents, ...eventsToAdd]); + // We want to be consistent and use our backend id - const correctId = response.data.id; - const { id, ...restEvent } = eventWithUser; - console.log(`Replace ${id} with ${correctId}`); - setAllEvents([...allEvents, { id: correctId, ...restEvent }]); + // const correctId = response.data.id; + // const { id, ...restEvent } = eventWithUser; + // console.log(`Replace ${id} with ${correctId}`); + // setAllEvents([...allEvents, { id: correctId, ...restEvent }]); }) .catch((error) => { console.error('Error saving event:', error);