fix: ics calendar export dates (#535)

* feat: academicCalendars object

* feat: seemingly working start, end, and until dates

* feat: seemingly working everything

* style: removed unnecessary deps, reorganized code

* style: code comments yay

* chore: old version of pnpm?

* ci: force github actions to rerun

* feat: list instructors in ics string, basic tests

* feat: testable code for ICS, tests for ICS, filter excluded dates

* style: eslint autofix

* test: check for graceful handling of errors in ICS

* fix: actually use scheduleToIcsString

* chore: eslint didn't include a space where it should've

* fix: ensure tz everywhere

* refactor: move string util to string util file

* feat: em dash in calendar event title

* feat: academic calendars 22-23 and 23-24

* fix: en dash instead of em dash
This commit is contained in:
Samuel Gunter
2025-03-22 22:55:16 -05:00
committed by GitHub
parent 3bed9cc27f
commit 4a5f67f0fd
8 changed files with 1080 additions and 36 deletions

View File

@@ -0,0 +1,200 @@
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}`;
type AcademicCalendarSemester = {
year: number;
semester: 'Fall' | 'Spring' | 'Summer';
firstClassDate: DateStr;
lastClassDate: DateStr;
breakDates: (DateStr | [DateStr, DateStr])[];
};
/**
* UT Austin's academic calendars, split by semester.
*
* See https://registrar.utexas.edu/calendars for future years.
*/
export const academicCalendars = {
'20229': {
year: 2022,
semester: 'Fall',
firstClassDate: '2022-08-22',
lastClassDate: '2022-12-05',
breakDates: [
'2022-09-05', // Labor Day holiday
['2022-11-21', '2022-11-26'], // Fall break / Thanksgiving
],
},
'20232': {
year: 2023,
semester: 'Spring',
firstClassDate: '2023-01-09',
lastClassDate: '2023-04-24',
breakDates: [
'2023-01-16', // Martin Luther King, Jr. Day
['2023-03-13', '2023-03-18'], // Spring Break
],
},
'20236': {
year: 2023,
semester: 'Summer',
firstClassDate: '2023-06-01',
lastClassDate: '2023-08-11',
breakDates: [
'2023-06-19', // Juneteenth holiday
'2023-07-04', // Independence Day holiday
],
},
'20239': {
year: 2023,
semester: 'Fall',
firstClassDate: '2023-08-21',
lastClassDate: '2023-12-04',
breakDates: [
'2023-09-04', // Labor Day holiday
['2023-11-20', '2023-11-25'], // Fall break / Thanksgiving
],
},
'20242': {
year: 2024,
semester: 'Spring',
firstClassDate: '2024-01-16',
lastClassDate: '2024-04-29',
breakDates: [
'2024-01-15', // Martin Luther King, Jr. Day
['2024-03-11', '2024-03-16'], // Spring Break
],
},
'20246': {
year: 2024,
semester: 'Summer',
firstClassDate: '2024-06-06',
lastClassDate: '2024-08-16',
breakDates: [
'2024-06-19', // Juneteenth holiday
'2024-07-04', // Independence Day holiday
],
},
'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>>;

View File

@@ -1,6 +1,31 @@
import { describe, expect, it } from 'vitest';
import { tz } from '@date-fns/tz';
import { Course } from '@shared/types/Course';
import { UserSchedule } from '@shared/types/UserSchedule';
import type { Serialized } from 'chrome-extension-toolkit';
import { format as formatDate, parseISO } from 'date-fns';
import {
chatterjeeCS429Course,
multiMeetingMultiInstructorCourse,
multiMeetingMultiInstructorSchedule,
} from 'src/stories/injected/mocked';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { formatToHHMMSS } from './utils';
import { allDatesInRanges, formatToHHMMSS, meetingToIcsString, nextDayInclusive, scheduleToIcsString } from './utils';
// Do all timezone calculations relative to UT's timezone
const TIMEZONE = 'America/Chicago';
const TZ = tz(TIMEZONE);
// Date and datetime formats used by iCal
const ISO_DATE_FORMAT = 'yyyy-MM-dd';
const ISO_BASIC_DATETIME_FORMAT = "yyyyMMdd'T'HHmmss";
/**
* Simulate serialized class instance, without the class's methods
*
* serde &lt;-- Serialize, Deserialize
*/
const serde = <T>(data: T) => JSON.parse(JSON.stringify(data)) as Serialized<T>;
describe('formatToHHMMSS', () => {
it('should format minutes to HHMMSS format', () => {
@@ -24,3 +49,431 @@ describe('formatToHHMMSS', () => {
expect(result).toBe(expected);
});
});
describe('nextDayInclusive', () => {
it('should return the same date if the given day is the same as the target day', () => {
const date = parseISO('2024-01-01', { in: TZ }); // Monday
const day = 1; // Monday
const result = nextDayInclusive(date, day);
expect(formatDate(result, ISO_DATE_FORMAT)).toBe('2024-01-01');
});
it('should return the next day if the given day is not the same as the target day', () => {
const date = parseISO('2024-07-18', { in: TZ }); // Thursday
const day = 2; // Tuesday
const result = nextDayInclusive(date, day);
expect(formatDate(result, ISO_DATE_FORMAT)).toBe('2024-07-23');
});
it('should wrap around years', () => {
const date = parseISO('2025-12-28', { in: TZ }); // Sunday
const day = 5; // Friday
const result = nextDayInclusive(date, day);
expect(formatDate(result, ISO_DATE_FORMAT)).toBe('2026-01-02');
});
it('should handle leap day', () => {
const date = parseISO('2024-02-27', { in: TZ }); // Tuesday
const day = 4; // Thursday
const result = nextDayInclusive(date, day);
expect(formatDate(result, ISO_DATE_FORMAT)).toBe('2024-02-29');
});
it('should handle an entire week of inputs', () => {
const date = parseISO('2024-08-20', { in: TZ }); // Tuesday
const days = [0, 1, 2, 3, 4, 5, 6] as const;
const results = days.map(day => nextDayInclusive(date, day));
const resultsFormatted = results.map(result => formatDate(result, ISO_DATE_FORMAT));
const expectedResults = [
'2024-08-25',
'2024-08-26',
'2024-08-20', // Same date
'2024-08-21',
'2024-08-22',
'2024-08-23',
'2024-08-24',
];
for (let i = 0; i < days.length; i++) {
expect(resultsFormatted[i]).toBe(expectedResults[i]);
}
});
it('should maintain hours/minutes/seconds', () => {
const date = parseISO('20250115T143021', { in: TZ }); // Wednesday
const days = [0, 1, 2, 3, 4, 5, 6] as const;
const results = days.map(day => nextDayInclusive(date, day));
const resultsFormatted = results.map(result => formatDate(result, ISO_BASIC_DATETIME_FORMAT));
const expectedResults = [
'20250119T143021',
'20250120T143021',
'20250121T143021',
'20250115T143021',
'20250116T143021',
'20250117T143021',
'20250118T143021',
];
for (let i = 0; i < days.length; i++) {
expect(resultsFormatted[i]).toBe(expectedResults[i]);
}
});
});
describe('allDatesInRanges', () => {
it('should handle empty array', () => {
const dateRanges = [] satisfies string[];
const result = allDatesInRanges(dateRanges);
const expected = [] satisfies Date[];
expect(result).toEqual(expected);
});
it('should handle a single date', () => {
const dateRanges = ['2025-03-14'] satisfies (string | [string, string])[];
const result = allDatesInRanges(dateRanges);
const expected = ['2025-03-14'].map(dateStr => parseISO(dateStr, { in: TZ })) satisfies Date[];
expect(result).toEqual(expected);
});
it('should handle a single date', () => {
const dateRanges = ['2025-03-14'] satisfies string[];
const result = allDatesInRanges(dateRanges);
const expected = ['2025-03-14'].map(dateStr => parseISO(dateStr, { in: TZ })) satisfies Date[];
expect(result).toEqual(expected);
});
it('should handle a single date range', () => {
const dateRanges = [['2025-03-14', '2025-03-19']] satisfies (string | [string, string])[];
const result = allDatesInRanges(dateRanges);
const expected = ['2025-03-14', '2025-03-15', '2025-03-16', '2025-03-17', '2025-03-18', '2025-03-19'].map(
dateStr => parseISO(dateStr, { in: TZ })
) satisfies Date[];
expect(result).toEqual(expected);
});
it('should handle multiple dates/date ranges', () => {
const dateRanges = [
'2025-02-14',
['2025-03-14', '2025-03-19'],
'2026-12-01',
['2026-12-03', '2026-12-05'],
] satisfies (string | [string, string])[];
const result = allDatesInRanges(dateRanges);
const expected = [
'2025-02-14', // '2025-02-14'
'2025-03-14', // ['2025-03-14', '2025-03-19']
'2025-03-15',
'2025-03-16',
'2025-03-17',
'2025-03-18',
'2025-03-19',
'2026-12-01', // '2026-12-01'
'2026-12-03', // ['2026-12-03', '2026-12-05'
'2026-12-04',
'2026-12-05',
].map(dateStr => parseISO(dateStr, { in: TZ })) satisfies Date[];
expect(result).toEqual(expected);
});
it('should handle month-/year-spanning ranges', () => {
const dateRanges = [
['2023-02-27', '2023-03-02'],
['2023-12-27', '2024-01-03'],
] satisfies (string | [string, string])[];
const result = allDatesInRanges(dateRanges);
const expected = [
'2023-02-27', // ['2023-02-27', '2023-03-2']
'2023-02-28',
'2023-03-01',
'2023-03-02',
'2023-12-27', // ['2023-12-27', '2024-01-3']
'2023-12-28',
'2023-12-29',
'2023-12-30',
'2023-12-31',
'2024-01-01',
'2024-01-02',
'2024-01-03',
].map(dateStr => parseISO(dateStr, { in: TZ })) satisfies Date[];
expect(result).toEqual(expected);
});
it('should handle leap years', () => {
const dateRanges = [
['2023-02-27', '2023-03-02'],
['2024-02-27', '2024-03-02'],
['2025-02-27', '2025-03-02'],
] satisfies (string | [string, string])[];
const result = allDatesInRanges(dateRanges);
const expected = [
'2023-02-27', // ['2023-02-27', '2023-03-2']
'2023-02-28',
'2023-03-01',
'2023-03-02',
'2024-02-27', // ['2024-02-27', '2024-03-2']
'2024-02-28',
'2024-02-29',
'2024-03-01',
'2024-03-02',
'2025-02-27', // ['2025-02-27', '2025-03-2']
'2025-02-28',
'2025-03-01',
'2025-03-02',
].map(dateStr => parseISO(dateStr, { in: TZ })) satisfies Date[];
expect(result).toEqual(expected);
});
});
describe('meetingToIcsString', () => {
it('should handle a one-day meeting with one instructor', () => {
const course = serde(multiMeetingMultiInstructorCourse);
course.instructors = course.instructors.slice(0, 1);
const meeting = course.schedule.meetings[1]!;
const result = meetingToIcsString(course, meeting);
const expected = (
`BEGIN:VEVENT
DTSTART;TZID=America/Chicago:20250829T130000
DTEND;TZID=America/Chicago:20250829T160000
RRULE:FREQ=WEEKLY;BYDAY=FR;UNTIL=20251209T060000Z
EXDATE;TZID=America/Chicago:20251128T130000
` +
// Only skips one Thanksgiving break day
`SUMMARY:J 395 44-REPORTING TEXAS
LOCATION:DMC 3.208
DESCRIPTION:Unique number: 10335\\nTaught by John Schwartz
END:VEVENT`
).replaceAll(/^\s+/gm, '');
expect(result).toBe(expected);
});
it('should handle unique numbers below 5 digits', () => {
const course = serde(multiMeetingMultiInstructorCourse);
course.instructors = course.instructors.slice(0, 1);
course.uniqueId = 4269;
const meeting = course.schedule.meetings[1]!;
const result = meetingToIcsString(course, meeting);
const expected = `BEGIN:VEVENT
DTSTART;TZID=America/Chicago:20250829T130000
DTEND;TZID=America/Chicago:20250829T160000
RRULE:FREQ=WEEKLY;BYDAY=FR;UNTIL=20251209T060000Z
EXDATE;TZID=America/Chicago:20251128T130000
SUMMARY:J 395 44-REPORTING TEXAS
LOCATION:DMC 3.208
DESCRIPTION:Unique number: 04269\\nTaught by John Schwartz
END:VEVENT`.replaceAll(/^\s+/gm, '');
expect(result).toBe(expected);
});
it('should handle a one-day meeting with multiple instructors', () => {
const course = serde(multiMeetingMultiInstructorCourse);
const meeting = course.schedule.meetings[1]!;
const result = meetingToIcsString(course, meeting);
const expected = `BEGIN:VEVENT
DTSTART;TZID=America/Chicago:20250829T130000
DTEND;TZID=America/Chicago:20250829T160000
RRULE:FREQ=WEEKLY;BYDAY=FR;UNTIL=20251209T060000Z
EXDATE;TZID=America/Chicago:20251128T130000
SUMMARY:J 395 44-REPORTING TEXAS
LOCATION:DMC 3.208
DESCRIPTION:Unique number: 10335\\nTaught by John Schwartz and John Bridges
END:VEVENT`.replaceAll(/^\s+/gm, '');
expect(result).toBe(expected);
});
it('should gracefully error on an out of range semester code', () => {
const course = serde(multiMeetingMultiInstructorCourse);
const meeting = course.schedule.meetings[0]!;
vi.spyOn(console, 'error').mockReturnValue(undefined);
course.semester = {
season: 'Fall',
year: 2010,
code: '20109',
};
const result = meetingToIcsString(course, meeting);
expect(result).toBeNull();
expect(console.error).toBeCalledWith(
`No academic calendar found for semester code: 20109; course uniqueId: ${course.uniqueId}`
);
});
it('should handle a multi-day meeting with multiple instructors', () => {
const course = serde(multiMeetingMultiInstructorCourse);
const meeting = course.schedule.meetings[0]!;
const result = meetingToIcsString(course, meeting);
const expected = `BEGIN:VEVENT
DTSTART;TZID=America/Chicago:20250826T093000
DTEND;TZID=America/Chicago:20250826T110000
RRULE:FREQ=WEEKLY;BYDAY=TU,TH;UNTIL=20251209T060000Z
EXDATE;TZID=America/Chicago:20251125T093000,20251127T093000
SUMMARY:J 395 44-REPORTING TEXAS
LOCATION:CMA 6.146
DESCRIPTION:Unique number: 10335\\nTaught by John Schwartz and John Bridges
END:VEVENT`.replaceAll(/^\s+/gm, '');
expect(result).toBe(expected);
});
afterEach(() => {
vi.restoreAllMocks();
});
});
describe('scheduleToIcsString', () => {
it('should handle an empty schedule', () => {
const schedule = serde(
new UserSchedule({
courses: [],
hours: 0,
id: 'fajowe',
name: 'fajowe',
updatedAt: Date.now(),
})
);
const result = scheduleToIcsString(schedule);
const expected = `BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
X-WR-CALNAME:My Schedule
END:VCALENDAR`.replaceAll(/^\s+/gm, '');
expect(result).toBe(expected);
});
it('should handle a schedule with courses but no meetings', () => {
const schedule = serde(
new UserSchedule({
courses: [
new Course({
...multiMeetingMultiInstructorCourse,
schedule: {
meetings: [],
},
}),
],
hours: 0,
id: 'fajowe',
name: 'fajowe',
updatedAt: Date.now(),
})
);
const result = scheduleToIcsString(schedule);
const expected = `BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
X-WR-CALNAME:My Schedule
END:VCALENDAR`.replaceAll(/^\s+/gm, '');
expect(result).toBe(expected);
});
it('should handle a schedule with courses but out-of-range semester', () => {
vi.spyOn(console, 'error').mockReturnValue(undefined);
const schedule = serde(
new UserSchedule({
courses: [
new Course({
...multiMeetingMultiInstructorCourse,
semester: {
season: 'Fall',
year: 2010,
code: '20109',
},
}),
],
hours: 0,
id: 'fajowe',
name: 'fajowe',
updatedAt: Date.now(),
})
);
const result = scheduleToIcsString(schedule);
const expected = `BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
X-WR-CALNAME:My Schedule
END:VCALENDAR`.replaceAll(/^\s+/gm, '');
expect(result).toBe(expected);
});
it('should handle a single course with multiple meetings', () => {
const schedule = serde(multiMeetingMultiInstructorSchedule);
const result = scheduleToIcsString(schedule);
const expected = `BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
X-WR-CALNAME:My Schedule
BEGIN:VEVENT
DTSTART;TZID=America/Chicago:20250826T093000
DTEND;TZID=America/Chicago:20250826T110000
RRULE:FREQ=WEEKLY;BYDAY=TU,TH;UNTIL=20251209T060000Z
EXDATE;TZID=America/Chicago:20251125T093000,20251127T093000
SUMMARY:J 395 44-REPORTING TEXAS
LOCATION:CMA 6.146
DESCRIPTION:Unique number: 10335\\nTaught by John Schwartz and John Bridges
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=America/Chicago:20250829T130000
DTEND;TZID=America/Chicago:20250829T160000
RRULE:FREQ=WEEKLY;BYDAY=FR;UNTIL=20251209T060000Z
EXDATE;TZID=America/Chicago:20251128T130000
SUMMARY:J 395 44-REPORTING TEXAS
LOCATION:DMC 3.208
DESCRIPTION:Unique number: 10335\\nTaught by John Schwartz and John Bridges
END:VEVENT
END:VCALENDAR`.replaceAll(/^\s+/gm, '');
expect(result).toBe(expected);
});
it('should handle a complex schedule', () => {
const schedule = serde(multiMeetingMultiInstructorSchedule);
schedule.courses.push(chatterjeeCS429Course);
const result = scheduleToIcsString(schedule);
const expected = (
`BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
X-WR-CALNAME:My Schedule
BEGIN:VEVENT
DTSTART;TZID=America/Chicago:20250826T093000
DTEND;TZID=America/Chicago:20250826T110000
RRULE:FREQ=WEEKLY;BYDAY=TU,TH;UNTIL=20251209T060000Z
EXDATE;TZID=America/Chicago:20251125T093000,20251127T093000
SUMMARY:J 395 44-REPORTING TEXAS
LOCATION:CMA 6.146
DESCRIPTION:Unique number: 10335\\nTaught by John Schwartz and John Bridges
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=America/Chicago:20250829T130000
DTEND;TZID=America/Chicago:20250829T160000
RRULE:FREQ=WEEKLY;BYDAY=FR;UNTIL=20251209T060000Z
EXDATE;TZID=America/Chicago:20251128T130000
SUMMARY:J 395 44-REPORTING TEXAS
LOCATION:DMC 3.208
DESCRIPTION:Unique number: 10335\\nTaught by John Schwartz and John Bridges
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=America/Chicago:20250825T160000
DTEND;TZID=America/Chicago:20250825T170000
RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH;UNTIL=20251209T060000Z
` +
// Skips Labor Day and only relevant days of Thanksgiving
`EXDATE;TZID=America/Chicago:20250901T160000,20251124T160000,20251125T160000,20251126T160000,20251127T160000
SUMMARY:C S 429 COMP ORGANIZATN AND ARCH
LOCATION:UTC 3.102
DESCRIPTION:Unique number: 54795\\nTaught by Siddhartha Chatterjee
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=America/Chicago:20250829T090000
DTEND;TZID=America/Chicago:20250829T110000
RRULE:FREQ=WEEKLY;BYDAY=FR;UNTIL=20251209T060000Z
EXDATE;TZID=America/Chicago:20251128T090000
SUMMARY:C S 429 COMP ORGANIZATN AND ARCH
LOCATION:GSB 2.122
DESCRIPTION:Unique number: 54795\\nTaught by Siddhartha Chatterjee
END:VEVENT
END:VCALENDAR`
).replaceAll(/^\s+/gm, '');
expect(result).toBe(expected);
});
afterEach(() => {
vi.restoreAllMocks();
});
});

View File

@@ -1,9 +1,36 @@
import { tz, TZDate } from '@date-fns/tz';
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
import type { Course } from '@shared/types/Course';
import type { CourseMeeting } from '@shared/types/CourseMeeting';
import Instructor from '@shared/types/Instructor';
import type { UserSchedule } from '@shared/types/UserSchedule';
import { downloadBlob } from '@shared/util/downloadBlob';
import { englishStringifyList } from '@shared/util/string';
import type { Serialized } from 'chrome-extension-toolkit';
import type { DateArg, Day } from 'date-fns';
import {
addDays,
eachDayOfInterval,
format as formatDate,
formatISO,
getDay,
nextDay,
parseISO,
set as setMultiple,
} from 'date-fns';
import { toBlob } from 'html-to-image';
import { academicCalendars } from './academic-calendars';
// Do all timezone calculations relative to UT's timezone
const TIMEZONE_ID = 'America/Chicago';
const TZ = tz(TIMEZONE_ID);
// Datetime format used by iCal, not directly supported by date-fns
// (date-fns adds the timezone to the end, but iCal doesn't want it)
const ISO_BASIC_DATETIME_FORMAT = "yyyyMMdd'T'HHmmss";
// iCal uses two-letter codes for days of the week
export const CAL_MAP = {
Sunday: 'SU',
Monday: 'MO',
@@ -14,6 +41,17 @@ export const CAL_MAP = {
Saturday: 'SA',
} as const satisfies Record<string, string>;
// Date objects' day field goes by index like this
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.
@@ -38,50 +76,186 @@ export const formatToHHMMSS = (minutes: number) => {
return `${hours}${mins}00`;
};
/**
* Formats a date in the format YYYYMMDD'T'HHmmss, which is the format used by iCal.
*
* @param date - The date to format.
* @returns The formatted date string.
*/
const iCalDateFormat = <DateType extends Date>(date: DateArg<DateType>) =>
formatDate(date, ISO_BASIC_DATETIME_FORMAT, { in: TZ });
/**
* 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.
*/
export const nextDayInclusive = (date: Date, day: Day): TZDate => {
if (getDay(date, { in: TZ }) === day) {
return new TZDate(date, TIMEZONE_ID);
}
return nextDay(date, day, { in: TZ });
};
/**
* Returns an array of all the dates (as Date objects) in the given date ranges.
*
* @param dateRanges - An array of date ranges.
* Each date range can be a string (in which case it is interpreted as a single date)
* or an array of two strings (in which case it is interpreted as a date range, inclusive).
* @returns An array of all the dates (as Date objects) in the given date ranges.
*
* @example
* allDatesInRanges(['2025-01-01', ['2025-03-14', '2025-03-16']]) // ['2025-01-01', '2025-03-14', '2025-03-15', '2025-03-16'] (as Date objects)
*
* @remarks Does not remove duplicate dates.
*/
export const allDatesInRanges = (dateRanges: readonly (string | [string, string])[]): Date[] =>
dateRanges.flatMap(breakDate => {
if (Array.isArray(breakDate)) {
return eachDayOfInterval({
start: parseISO(breakDate[0], { in: TZ }),
end: parseISO(breakDate[1], { in: TZ }),
});
}
return parseISO(breakDate, { in: TZ });
});
/**
* Creates a VEVENT string for a meeting of a course.
*
* @param course - The course object
* @param meeting - The meeting object
* @returns A string representation of the meeting in the iCalendar format (ICS)
*/
export const meetingToIcsString = (course: Serialized<Course>, meeting: Serialized<CourseMeeting>): string | null => {
const { startTime, endTime, days, location } = meeting;
if (!course.semester.code) {
console.error(`No semester found for course uniqueId: ${course.uniqueId}`);
return null;
}
if (days.length === 0) {
console.error(`No days found for course uniqueId: ${course.uniqueId}`);
return null;
}
if (!Object.prototype.hasOwnProperty.call(academicCalendars, course.semester.code)) {
console.error(
`No academic calendar found for semester code: ${course.semester.code}; course uniqueId: ${course.uniqueId}`
);
return null;
}
const academicCalendar = academicCalendars[course.semester.code as keyof typeof academicCalendars];
const startDate = nextDayInclusive(
parseISO(academicCalendar.firstClassDate, { in: TZ }),
DAY_NAME_TO_NUMBER[days[0]!]
);
const startTimeHours = Math.floor(startTime / 60);
const startTimeMinutes = startTime % 60;
const startTimeDate = setMultiple(startDate, { hours: startTimeHours, minutes: startTimeMinutes }, { in: TZ });
const endTimeHours = Math.floor(endTime / 60);
const endTimeMinutes = endTime % 60;
const endTimeDate = setMultiple(startDate, { hours: endTimeHours, minutes: endTimeMinutes }, { in: TZ });
const untilDate = addDays(parseISO(academicCalendar.lastClassDate, { in: TZ }), 1);
const daysNumSet = new Set(days.map(d => DAY_NAME_TO_NUMBER[d]));
const excludedDates = allDatesInRanges(academicCalendar.breakDates)
// Don't need to exclude Tues/Thurs if it's a MWF class, etc.
.filter(date => daysNumSet.has(getDay(date, { in: TZ }) as Day))
.map(date => setMultiple(date, { hours: startTimeHours, minutes: startTimeMinutes }, { in: TZ }));
const startDateFormatted = iCalDateFormat(startTimeDate);
const endDateFormatted = iCalDateFormat(endTimeDate);
// Convert 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));
const uniqueNumberFormatted = course.uniqueId.toString().padStart(5, '0');
// The list part of "Taught by Michael Scott and Siddhartha Chatterjee Beasley"
const instructorsFormatted = englishStringifyList(
course.instructors
.map(instructor => Instructor.prototype.toString.call(instructor, { format: 'first_last' }))
.filter(name => name !== '')
);
// Construct event string
let icsString = 'BEGIN:VEVENT\n';
icsString += `DTSTART;TZID=${TIMEZONE_ID}:${startDateFormatted}\n`;
icsString += `DTEND;TZID=${TIMEZONE_ID}:${endDateFormatted}\n`;
icsString += `RRULE:FREQ=WEEKLY;BYDAY=${icsDays};UNTIL=${untilDateFormatted}\n`;
icsString += `EXDATE;TZID=${TIMEZONE_ID}:${excludedDatesFormatted.join(',')}\n`;
icsString += `SUMMARY:${course.department} ${course.number} \u2013 ${course.courseName}\n`;
if (location?.building || location?.building) {
const locationFormatted = `${location?.building ?? ''} ${location?.room ?? ''}`.trim();
icsString += `LOCATION:${locationFormatted}\n`;
}
icsString += `DESCRIPTION:Unique number: ${uniqueNumberFormatted}`;
if (instructorsFormatted) {
// Newlines need to be double-escaped
icsString += `\\nTaught by ${instructorsFormatted}`;
}
icsString += '\n';
icsString += 'END:VEVENT';
return icsString;
};
/**
* Creates a VCALENDAR string for a schedule of a user.
* @param schedule - The schedule object
* @returns A string representation of the schedule in the iCalendar format (ICS)
*/
export const scheduleToIcsString = (schedule: Serialized<UserSchedule>) => {
let icsString = 'BEGIN:VCALENDAR\nVERSION:2.0\nCALSCALE:GREGORIAN\nX-WR-CALNAME:My Schedule\n';
const vevents = schedule.courses
.flatMap(course => course.schedule.meetings.map(meeting => meetingToIcsString(course, meeting)))
.filter(event => event !== null)
.join('\n');
if (vevents.length > 0) {
icsString += `${vevents}\n`;
}
icsString += 'END:VCALENDAR';
return icsString;
};
/**
* 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.
* Downloads the ICS file to the user's device.
*/
export const saveAsCal = async () => {
const schedule = await getSchedule(); // Assumes this fetches the current active schedule
let icsString = 'BEGIN:VCALENDAR\nVERSION:2.0\nCALSCALE:GREGORIAN\nX-WR-CALNAME:My Schedule\n';
const schedule = await getSchedule();
if (!schedule) {
throw new Error('No schedule found');
}
schedule.courses.forEach(course => {
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);
// Map days to ICS compatible format
console.log(days);
const icsDays = days.map(day => CAL_MAP[day]).join(',');
console.log(icsDays);
// 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}`;
icsString += `BEGIN:VEVENT\n`;
icsString += `DTSTART:${startDate}\n`;
icsString += `DTEND:${endDate}\n`;
icsString += `RRULE:FREQ=WEEKLY;BYDAY=${icsDays}\n`;
icsString += `SUMMARY:${course.fullName}\n`;
icsString += `LOCATION:${location?.building ?? ''} ${location?.room ?? ''}\n`;
icsString += `END:VEVENT\n`;
});
});
icsString += 'END:VCALENDAR';
const icsString = scheduleToIcsString(schedule);
downloadBlob(icsString, 'CALENDAR', 'schedule.ics');
};