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:
@@ -27,6 +27,7 @@
|
|||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@date-fns/tz": "^1.2.0",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
"chrome-extension-toolkit": "^0.0.54",
|
"chrome-extension-toolkit": "^0.0.54",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"conventional-changelog": "^6.0.0",
|
"conventional-changelog": "^6.0.0",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"highcharts": "^11.4.8",
|
"highcharts": "^11.4.8",
|
||||||
"highcharts-react-official": "^3.2.1",
|
"highcharts-react-official": "^3.2.1",
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
|
|||||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@@ -19,6 +19,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@date-fns/tz':
|
||||||
|
specifier: ^1.2.0
|
||||||
|
version: 1.2.0
|
||||||
'@dnd-kit/core':
|
'@dnd-kit/core':
|
||||||
specifier: ^6.3.1
|
specifier: ^6.3.1
|
||||||
version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.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:
|
conventional-changelog:
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
version: 6.0.0(conventional-commits-filter@5.0.0)
|
version: 6.0.0(conventional-commits-filter@5.0.0)
|
||||||
|
date-fns:
|
||||||
|
specifier: ^4.1.0
|
||||||
|
version: 4.1.0
|
||||||
highcharts:
|
highcharts:
|
||||||
specifier: ^11.4.8
|
specifier: ^11.4.8
|
||||||
version: 11.4.8
|
version: 11.4.8
|
||||||
@@ -571,6 +577,9 @@ packages:
|
|||||||
'@crxjs/vite-plugin@2.0.0-beta.21':
|
'@crxjs/vite-plugin@2.0.0-beta.21':
|
||||||
resolution: {integrity: sha512-kSXgHHqCXASqJ8NmY94+KLGVwdtkJ0E7KsRQ+vbMpRliJ5ze0xnSk0l41p4txlUysmEoqaeo4Xb7rEFdcU2zjQ==}
|
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':
|
'@dnd-kit/accessibility@3.1.1':
|
||||||
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3269,6 +3278,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
|
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
date-fns@4.1.0:
|
||||||
|
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
|
||||||
|
|
||||||
debug@2.6.9:
|
debug@2.6.9:
|
||||||
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
|
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -7365,6 +7377,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@date-fns/tz@1.2.0': {}
|
||||||
|
|
||||||
'@dnd-kit/accessibility@3.1.1(react@18.3.1)':
|
'@dnd-kit/accessibility@3.1.1(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
@@ -10237,6 +10251,8 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
is-data-view: 1.0.2
|
is-data-view: 1.0.2
|
||||||
|
|
||||||
|
date-fns@4.1.0: {}
|
||||||
|
|
||||||
debug@2.6.9:
|
debug@2.6.9:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.0.0
|
ms: 2.0.0
|
||||||
|
|||||||
@@ -48,3 +48,22 @@ export const ellipsify = (input: string, chars: number): string => {
|
|||||||
}
|
}
|
||||||
return ellipisifed;
|
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)}`;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
// TODO: Fix `string.ts` and `string.test.ts` to make the tests pass
|
// TODO: Fix `string.ts` and `string.test.ts` to make the tests pass
|
||||||
@@ -54,3 +54,49 @@ describe('ellipsify', () => {
|
|||||||
expect(ellipsify('', 5)).toBe('');
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -174,3 +174,137 @@ export const mikeScottCS314Schedule: UserSchedule = new UserSchedule({
|
|||||||
hours: 3,
|
hours: 3,
|
||||||
updatedAt: Date.now(),
|
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/',
|
||||||
|
});
|
||||||
|
|||||||
200
src/views/components/calendar/academic-calendars.ts
Normal file
200
src/views/components/calendar/academic-calendars.ts
Normal 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>>;
|
||||||
@@ -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', () => {
|
describe('formatToHHMMSS', () => {
|
||||||
it('should format minutes to HHMMSS format', () => {
|
it('should format minutes to HHMMSS format', () => {
|
||||||
@@ -24,3 +49,431 @@ describe('formatToHHMMSS', () => {
|
|||||||
expect(result).toBe(expected);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,9 +1,36 @@
|
|||||||
|
import { tz, TZDate } from '@date-fns/tz';
|
||||||
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
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 type { UserSchedule } from '@shared/types/UserSchedule';
|
||||||
import { downloadBlob } from '@shared/util/downloadBlob';
|
import { downloadBlob } from '@shared/util/downloadBlob';
|
||||||
|
import { englishStringifyList } from '@shared/util/string';
|
||||||
import type { Serialized } from 'chrome-extension-toolkit';
|
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 { 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 = {
|
export const CAL_MAP = {
|
||||||
Sunday: 'SU',
|
Sunday: 'SU',
|
||||||
Monday: 'MO',
|
Monday: 'MO',
|
||||||
@@ -14,6 +41,17 @@ export const CAL_MAP = {
|
|||||||
Saturday: 'SA',
|
Saturday: 'SA',
|
||||||
} as const satisfies Record<string, string>;
|
} 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.
|
* Retrieves the schedule from the UserScheduleStore based on the active index.
|
||||||
* @returns A promise that resolves to the retrieved schedule.
|
* @returns A promise that resolves to the retrieved schedule.
|
||||||
@@ -38,50 +76,186 @@ export const formatToHHMMSS = (minutes: number) => {
|
|||||||
return `${hours}${mins}00`;
|
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).
|
* 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.
|
* Fetches the current active schedule and converts it into an ICS string.
|
||||||
* Downloads the ICS file to the user's device.
|
* Downloads the ICS file to the user's device.
|
||||||
*/
|
*/
|
||||||
export const saveAsCal = async () => {
|
export const saveAsCal = async () => {
|
||||||
const schedule = await getSchedule(); // Assumes this fetches the current active schedule
|
const schedule = await getSchedule();
|
||||||
|
|
||||||
let icsString = 'BEGIN:VCALENDAR\nVERSION:2.0\nCALSCALE:GREGORIAN\nX-WR-CALNAME:My Schedule\n';
|
|
||||||
|
|
||||||
if (!schedule) {
|
if (!schedule) {
|
||||||
throw new Error('No schedule found');
|
throw new Error('No schedule found');
|
||||||
}
|
}
|
||||||
|
|
||||||
schedule.courses.forEach(course => {
|
const icsString = scheduleToIcsString(schedule);
|
||||||
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';
|
|
||||||
|
|
||||||
downloadBlob(icsString, 'CALENDAR', 'schedule.ics');
|
downloadBlob(icsString, 'CALENDAR', 'schedule.ics');
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user