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:
@@ -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 <-- 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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user