Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: ics calendar export dates #535

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"prepare": "husky"
},
"dependencies": {
"@date-fns/tz": "^1.2.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
Expand All @@ -40,6 +41,7 @@
"chrome-extension-toolkit": "^0.0.54",
"clsx": "^2.1.1",
"conventional-changelog": "^6.0.0",
"date-fns": "^4.1.0",
"highcharts": "^11.4.8",
"highcharts-react-official": "^3.2.1",
"html-to-image": "^1.11.13",
Expand Down
16 changes: 16 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

135 changes: 135 additions & 0 deletions src/views/components/calendar/academic-calendars.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
type Year = `20${Digit}${Digit}`;
type Month = `0${Exclude<Digit, 0>}` | `1${'0' | '1' | '2'}`;
type Day = `0${Exclude<Digit, 0>}` | `${1 | 2}${Digit}` | '30' | '31';
type DateStr = `${Year}-${Month}-${Day}`;
type SemesterDigit = 2 | 6 | 9;
type SemesterIdentifier = `20${Digit}${Digit}${SemesterDigit}`;
Comment on lines +1 to +7
Copy link
Collaborator

Choose a reason for hiding this comment

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

Good use of Template Literal Types


type AcademicCalendarSemester = {
year: number;
semester: 'Fall' | 'Spring' | 'Summer';
firstClassDate: DateStr;
lastClassDate: DateStr;
breakDates: (DateStr | [DateStr, DateStr])[];
};

export const academicCalendars = {
'20249': {
year: 2024,
semester: 'Fall',
firstClassDate: '2024-08-26',
lastClassDate: '2024-12-09',
breakDates: [
'2024-09-02', // Labor Day holiday
['2024-11-25', '2024-11-30'], // Fall break / Thanksgiving
],
},
'20252': {
year: 2025,
semester: 'Spring',
firstClassDate: '2025-01-13',
lastClassDate: '2025-04-28',
breakDates: [
'2025-01-20', // Martin Luther King, Jr. Day
['2025-03-17', '2025-03-22'], // Spring Break
],
},
'20256': {
year: 2025,
semester: 'Summer',
firstClassDate: '2025-06-05',
lastClassDate: '2025-08-15',
breakDates: [
'2025-06-19', // Juneteenth holiday
'2025-07-04', // Independence Day holiday
],
},
'20259': {
year: 2025,
semester: 'Fall',
firstClassDate: '2025-08-25',
lastClassDate: '2025-12-08',
breakDates: [
'2025-09-01', // Labor Day holiday
['2025-11-24', '2025-11-29'], // Fall break / Thanksgiving
],
},
'20262': {
year: 2026,
semester: 'Spring',
firstClassDate: '2026-01-12',
lastClassDate: '2026-04-27',
breakDates: [
'2026-01-19', // Martin Luther King, Jr. Day
['2026-03-16', '2026-03-21'], // Spring Break
],
},
'20266': {
year: 2026,
semester: 'Summer',
firstClassDate: '2026-06-04',
lastClassDate: '2026-08-14',
breakDates: [
'2026-06-19', // Juneteenth holiday
'2026-07-04', // Independence Day holiday
],
},
'20269': {
year: 2026,
semester: 'Fall',
firstClassDate: '2026-08-24',
lastClassDate: '2026-12-07',
breakDates: [
'2026-09-07', // Labor Day holiday
['2026-11-23', '2026-11-28'], // Fall break / Thanksgiving
],
},
'20272': {
year: 2027,
semester: 'Spring',
firstClassDate: '2027-01-11',
lastClassDate: '2027-04-26',
breakDates: [
'2027-01-18', // Martin Luther King, Jr. Day
['2027-03-15', '2027-03-20'], // Spring Break
],
},
'20276': {
year: 2027,
semester: 'Summer',
firstClassDate: '2027-06-03',
lastClassDate: '2027-08-13',
breakDates: [
'2027-07-04', // Independence Day holiday
],
},
'20279': {
year: 2027,
semester: 'Fall',
firstClassDate: '2027-08-23',
lastClassDate: '2027-12-06',
breakDates: [
'2027-09-06', // Labor Day holiday
['2027-11-22', '2027-11-27'], // Fall break / Thanksgiving
],
},
'20282': {
year: 2028,
semester: 'Spring',
firstClassDate: '2028-01-18',
lastClassDate: '2028-05-01',
breakDates: [
['2028-03-13', '2028-03-18'], // Spring Break
],
},
'20286': {
year: 2028,
semester: 'Summer',
firstClassDate: '2028-06-08',
lastClassDate: '2028-08-18',
breakDates: [
'2028-07-04', // Independence Day holiday
],
},
} as const satisfies Partial<Record<SemesterIdentifier, AcademicCalendarSemester>>;
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍

147 changes: 132 additions & 15 deletions src/views/components/calendar/utils.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,27 @@
import { tz } from '@date-fns/tz';
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
import type { UserSchedule } from '@shared/types/UserSchedule';
import { downloadBlob } from '@shared/util/downloadBlob';
import type { Serialized } from 'chrome-extension-toolkit';
import type { DateArg, Day } from 'date-fns';
import {
addDays,
eachDayOfInterval,
format as formatDate,
formatISO,
getDay,
nextDay,
parse,
set as setMultiple,
toDate,
} from 'date-fns';
import { toBlob } from 'html-to-image';

import { academicCalendars } from './academic-calendars';

const TIMEZONE = 'America/Chicago';
const TZ = tz(TIMEZONE);

export const CAL_MAP = {
Sunday: 'SU',
Monday: 'MO',
Expand All @@ -14,6 +32,16 @@ export const CAL_MAP = {
Saturday: 'SA',
} as const satisfies Record<string, string>;

const DAY_NAME_TO_NUMBER = {
Sunday: 0,
Monday: 1,
Tuesday: 2,
Wednesday: 3,
Thursday: 4,
Friday: 5,
Saturday: 6,
} as const satisfies Record<string, number>;

/**
* Retrieves the schedule from the UserScheduleStore based on the active index.
* @returns A promise that resolves to the retrieved schedule.
Expand All @@ -38,6 +66,39 @@ export const formatToHHMMSS = (minutes: number) => {
return `${hours}${mins}00`;
};

/**
* Formats a date in the format YYYYMMDD'T'HHmmss.
*
* @param date - The date to format.
* @returns
*/
const iCalDateFormat = <DateType extends Date>(date: DateArg<DateType>) =>
formatDate(date, "yyyyMMdd'T'HHmmss", { in: TZ });

const ISO_DATE_FORMAT = 'yyyy-MM-dd';

/**
* Returns the next day of the given date, inclusive of the given day.
*
* If the given date is the given day, the same date is returned.
*
* For example, a Monday targeting a Wednesday will return the next Wednesday, but if it was targeting a Monday it would return the same date.
*
* @param date - The date to increment.
* @param day - The day to increment to. (0 = Sunday, 1 = Monday, etc.)
* @returns The next day of the given date, inclusive of the given day.
*/
const nextDayInclusive = <DateType extends Date, ResultDate extends Date = DateType>(
date: DateArg<DateType>,
day: Day
): ResultDate => {
if (getDay(date) === day) {
return toDate(date);
}

return nextDay(date, day);
};

/**
* Saves the current schedule as a calendar file in the iCalendar format (ICS).
* Fetches the current active schedule and converts it into an ICS string.
Expand All @@ -56,25 +117,81 @@ export const saveAsCal = async () => {
course.schedule.meetings.forEach(meeting => {
const { startTime, endTime, days, location } = meeting;

// Format start and end times to HHMMSS
const formattedStartTime = formatToHHMMSS(startTime);
const formattedEndTime = formatToHHMMSS(endTime);
if (!course.semester.code) {
console.error(`No semester found for course uniqueId: ${course.uniqueId}`);
return;
}

if (days.length === 0) {
console.error(`No days found for course uniqueId: ${course.uniqueId}`);
return;
}

// Map days to ICS compatible format
console.log(days);
const icsDays = days.map(day => CAL_MAP[day]).join(',');
console.log(icsDays);
const academicCalendar = academicCalendars[course.semester.code as keyof typeof academicCalendars];

// Assuming course has date started and ended, adapt as necessary
// const year = new Date().getFullYear(); // Example year, adapt accordingly
// Example event date, adapt startDate according to your needs
const startDate = `20240101T${formattedStartTime}`;
const endDate = `20240101T${formattedEndTime}`;
if (!academicCalendar) {
console.error(
`No academic calendar found for semester code: ${course.semester.code}; course uniqueId: ${course.uniqueId}`
);
}

const startDate = nextDayInclusive(
parse(academicCalendar.firstClassDate, ISO_DATE_FORMAT, new Date()),
DAY_NAME_TO_NUMBER[days[0]!]
);

const startTimeDate = setMultiple(
startDate,
{
hours: Math.floor(startTime / 60),
minutes: startTime % 60,
},
{ in: TZ }
);

const endTimeDate = setMultiple(
startDate,
{ hours: Math.floor(endTime / 60), minutes: endTime % 60 },
{ in: TZ }
);

const untilDate = addDays(parse(academicCalendar.lastClassDate, ISO_DATE_FORMAT, new Date()), 1);

const excludedDates = academicCalendar.breakDates
.flatMap(breakDate => {
if (Array.isArray(breakDate)) {
return eachDayOfInterval({
start: parse(breakDate[0], ISO_DATE_FORMAT, new Date()),
end: parse(breakDate[1], ISO_DATE_FORMAT, new Date()),
});
}

return parse(breakDate, ISO_DATE_FORMAT, new Date());
})
.map(date =>
setMultiple(
date,
{
hours: Math.floor(startTime / 60),
minutes: startTime % 60,
},
{ in: TZ }
)
);

const startDateFormatted = iCalDateFormat(startTimeDate);
const endDateFormatted = iCalDateFormat(endTimeDate);
// Map days to ICS compatible format, e.g. MO,WE,FR
const icsDays = days.map(day => CAL_MAP[day]).join(',');
// per spec, UNTIL must be in UTC
const untilDateFormatted = formatISO(untilDate, { format: 'basic', in: tz('utc') });
const excludedDatesFormatted = excludedDates.map(date => iCalDateFormat(date));

icsString += `BEGIN:VEVENT\n`;
icsString += `DTSTART:${startDate}\n`;
icsString += `DTEND:${endDate}\n`;
icsString += `RRULE:FREQ=WEEKLY;BYDAY=${icsDays}\n`;
icsString += `DTSTART;TZID=America/Chicago:${startDateFormatted}\n`;
icsString += `DTEND;TZID=America/Chicago:${endDateFormatted}\n`;
icsString += `RRULE:FREQ=WEEKLY;BYDAY=${icsDays};UNTIL=${untilDateFormatted}\n`;
icsString += `EXDATE;TZID=America/Chicago:${excludedDatesFormatted.join(',')}\n`;
icsString += `SUMMARY:${course.fullName}\n`;
icsString += `LOCATION:${location?.building ?? ''} ${location?.room ?? ''}\n`;
icsString += `END:VEVENT\n`;
Expand Down