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

@@ -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",
@@ -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",

16
pnpm-lock.yaml generated
View File

@@ -19,6 +19,9 @@ importers:
.:
dependencies:
'@date-fns/tz':
specifier: ^1.2.0
version: 1.2.0
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -58,6 +61,9 @@ importers:
conventional-changelog:
specifier: ^6.0.0
version: 6.0.0(conventional-commits-filter@5.0.0)
date-fns:
specifier: ^4.1.0
version: 4.1.0
highcharts:
specifier: ^11.4.8
version: 11.4.8
@@ -571,6 +577,9 @@ packages:
'@crxjs/vite-plugin@2.0.0-beta.21':
resolution: {integrity: sha512-kSXgHHqCXASqJ8NmY94+KLGVwdtkJ0E7KsRQ+vbMpRliJ5ze0xnSk0l41p4txlUysmEoqaeo4Xb7rEFdcU2zjQ==}
'@date-fns/tz@1.2.0':
resolution: {integrity: sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==}
'@dnd-kit/accessibility@3.1.1':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
@@ -3269,6 +3278,9 @@ packages:
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
engines: {node: '>= 0.4'}
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
debug@2.6.9:
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
peerDependencies:
@@ -7365,6 +7377,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@date-fns/tz@1.2.0': {}
'@dnd-kit/accessibility@3.1.1(react@18.3.1)':
dependencies:
react: 18.3.1
@@ -10237,6 +10251,8 @@ snapshots:
es-errors: 1.3.0
is-data-view: 1.0.2
date-fns@4.1.0: {}
debug@2.6.9:
dependencies:
ms: 2.0.0

View File

@@ -48,3 +48,22 @@ export const ellipsify = (input: string, chars: number): string => {
}
return ellipisifed;
};
/**
* Stringifies a list of items in English format.
*
* @param items - The list of items to stringify.
* @returns A string representation of the list in English format.
* @example
* englishStringifyList([]) // ''
* englishStringifyList(['Alice']) // 'Alice'
* englishStringifyList(['Alice', 'Bob']) // 'Alice and Bob'
* englishStringifyList(['Alice', 'Bob', 'Charlie']) // 'Alice, Bob, and Charlie'
*/
export const englishStringifyList = (items: readonly string[]): string => {
if (items.length === 0) return '';
if (items.length === 1) return items[0]!;
if (items.length === 2) return `${items[0]} and ${items[1]}`;
return `${items.slice(0, -1).join(', ')}, and ${items.at(-1)}`;
};

View File

@@ -1,4 +1,4 @@
import { capitalize, capitalizeFirstLetter, ellipsify } from '@shared/util/string';
import { capitalize, capitalizeFirstLetter, ellipsify, englishStringifyList } from '@shared/util/string';
import { describe, expect, it } from 'vitest';
// TODO: Fix `string.ts` and `string.test.ts` to make the tests pass
@@ -54,3 +54,49 @@ describe('ellipsify', () => {
expect(ellipsify('', 5)).toBe('');
});
});
describe('englishStringifyList', () => {
it('should handle an empty array', () => {
const data = [] satisfies string[];
const result = englishStringifyList(data);
const expected = '';
expect(result).toBe(expected);
});
it('should handle 1 element', () => {
const data = ['Alice'] satisfies string[];
const result = englishStringifyList(data);
const expected = 'Alice';
expect(result).toBe(expected);
});
it('should handle 2 elements', () => {
const data = ['Alice', 'Bob'] satisfies string[];
const result = englishStringifyList(data);
const expected = 'Alice and Bob';
expect(result).toBe(expected);
});
it('should handle 3 elements', () => {
const data = ['Alice', 'Bob', 'Charlie'] satisfies string[];
const result = englishStringifyList(data);
const expected = 'Alice, Bob, and Charlie';
expect(result).toBe(expected);
});
it('should handle n elements', () => {
const testcases = [
{ data: [], expected: '' },
{ data: ['foo'], expected: 'foo' },
{ data: ['foo', 'bar'], expected: 'foo and bar' },
{ data: ['foo', 'bar', 'baz'], expected: 'foo, bar, and baz' },
{ data: ['a', 'b', 'c', 'd'], expected: 'a, b, c, and d' },
{ data: 'abcdefghijk'.split(''), expected: 'a, b, c, d, e, f, g, h, i, j, and k' },
] satisfies { data: string[]; expected: string }[];
for (const { data, expected } of testcases) {
const result = englishStringifyList(data);
expect(result).toBe(expected);
}
});
});

View File

@@ -174,3 +174,137 @@ export const mikeScottCS314Schedule: UserSchedule = new UserSchedule({
hours: 3,
updatedAt: Date.now(),
});
export const multiMeetingMultiInstructorCourse: Course = new Course({
colors: {
primaryColor: '#ef4444',
secondaryColor: '#991b1b',
},
core: [],
courseName: '44-REPORTING TEXAS',
creditHours: 3,
department: 'J',
description: [
"Contemporary social, professional, and intellectual concerns with the practice of journalism. Students work as online reporters, photographers, and editors for the School of Journalism's Reporting Texas Web site.",
'Prerequisite: Graduate standing; additional prerequisites vary with the topic.',
'Designed to accommodate 35 or fewer students. Course number may be repeated for credit when the topics vary.',
],
flags: [],
fullName: 'J 395 44-REPORTING TEXAS',
instructionMode: 'In Person',
instructors: [
{
firstName: 'JOHN',
fullName: 'SCHWARTZ, JOHN R',
lastName: 'SCHWARTZ',
middleInitial: 'R',
},
{
firstName: 'JOHN',
fullName: 'BRIDGES, JOHN A III',
lastName: 'BRIDGES',
middleInitial: 'A',
},
],
isReserved: true,
number: '395',
schedule: {
meetings: [
{
days: ['Tuesday', 'Thursday'],
endTime: 660,
location: {
building: 'CMA',
room: '6.146',
},
startTime: 570,
},
{
days: ['Friday'],
endTime: 960,
location: {
building: 'DMC',
room: '3.208',
},
startTime: 780,
},
],
},
scrapedAt: 1742491957535,
semester: {
code: '20259',
season: 'Fall',
year: 2025,
},
status: 'OPEN',
uniqueId: 10335,
url: 'https://utdirect.utexas.edu/apps/registrar/course_schedule/20259/10335/',
});
export const multiMeetingMultiInstructorSchedule: UserSchedule = new UserSchedule({
courses: [multiMeetingMultiInstructorCourse],
id: 'mmmis',
name: 'Multi Meeting Multi Instructor Schedule',
hours: 3,
updatedAt: Date.now(),
});
export const chatterjeeCS429Course: Course = new Course({
colors: {
primaryColor: '#0284c7',
secondaryColor: '#0c4a6e',
},
core: [],
courseName: 'COMP ORGANIZATN AND ARCH',
creditHours: 4,
department: 'C S',
description: [
'Restricted to computer science majors.',
'An introduction to low-level computer design ranging from the basics of digital design to the hardware/software interface for application programs. Includes basic systems principles of pipelining and caching, and requires writing and understanding programs at multiple levels.',
'Computer Science 429 and 429H may not both be counted.',
'Prerequisite: The following courses with a grade of at least C-: Computer Science 311 or 311H; and Computer Science 314 or 314H.',
],
flags: [],
fullName: 'C S 429 COMP ORGANIZATN AND ARCH',
instructionMode: 'In Person',
instructors: [
{
firstName: 'SIDDHARTHA',
fullName: 'CHATTERJEE, SIDDHARTHA',
lastName: 'CHATTERJEE',
},
],
isReserved: true,
number: '429',
schedule: {
meetings: [
{
days: ['Monday', 'Tuesday', 'Wednesday', 'Thursday'],
endTime: 1020,
location: {
building: 'UTC',
room: '3.102',
},
startTime: 960,
},
{
days: ['Friday'],
endTime: 660,
location: {
building: 'GSB',
room: '2.122',
},
startTime: 540,
},
],
},
scrapedAt: 1742496630445,
semester: {
code: '20259',
season: 'Fall',
year: 2025,
},
status: 'OPEN',
uniqueId: 54795,
url: 'https://utdirect.utexas.edu/apps/registrar/course_schedule/20259/54795/',
});

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');
};