From 4a5f67f0fda9f0ef57f821e4b7a55d63f099f579 Mon Sep 17 00:00:00 2001 From: Samuel Gunter <29130894+Samathingamajig@users.noreply.github.com> Date: Sat, 22 Mar 2025 22:55:16 -0500 Subject: [PATCH] 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 --- package.json | 2 + pnpm-lock.yaml | 16 + src/shared/util/string.ts | 19 + src/shared/util/tests/string.test.ts | 48 +- src/stories/injected/mocked.ts | 134 +++++ .../components/calendar/academic-calendars.ts | 200 ++++++++ src/views/components/calendar/utils.test.ts | 457 +++++++++++++++++- src/views/components/calendar/utils.ts | 240 +++++++-- 8 files changed, 1080 insertions(+), 36 deletions(-) create mode 100644 src/views/components/calendar/academic-calendars.ts diff --git a/package.json b/package.json index a787b0c9..af18cd48 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80004001..37c32c91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/shared/util/string.ts b/src/shared/util/string.ts index 2d69bb84..cd746acc 100644 --- a/src/shared/util/string.ts +++ b/src/shared/util/string.ts @@ -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)}`; +}; diff --git a/src/shared/util/tests/string.test.ts b/src/shared/util/tests/string.test.ts index a9455885..268b4476 100644 --- a/src/shared/util/tests/string.test.ts +++ b/src/shared/util/tests/string.test.ts @@ -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); + } + }); +}); diff --git a/src/stories/injected/mocked.ts b/src/stories/injected/mocked.ts index 2b9f200a..8b1dd767 100644 --- a/src/stories/injected/mocked.ts +++ b/src/stories/injected/mocked.ts @@ -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/', +}); diff --git a/src/views/components/calendar/academic-calendars.ts b/src/views/components/calendar/academic-calendars.ts new file mode 100644 index 00000000..815ee2ff --- /dev/null +++ b/src/views/components/calendar/academic-calendars.ts @@ -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}` | `1${'0' | '1' | '2'}`; +type Day = `0${Exclude}` | `${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>; diff --git a/src/views/components/calendar/utils.test.ts b/src/views/components/calendar/utils.test.ts index cc2e6997..9093aa45 100644 --- a/src/views/components/calendar/utils.test.ts +++ b/src/views/components/calendar/utils.test.ts @@ -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 = (data: T) => JSON.parse(JSON.stringify(data)) as Serialized; 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(); + }); +}); diff --git a/src/views/components/calendar/utils.ts b/src/views/components/calendar/utils.ts index 5d83252f..aae07cb6 100644 --- a/src/views/components/calendar/utils.ts +++ b/src/views/components/calendar/utils.ts @@ -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; +// 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; + /** * 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 = (date: DateArg) => + 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, meeting: Serialized): 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) => { + 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'); };