From a92e772ace78e1f8c9dfd012f887a1190d1c87e9 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:49:40 +0000 Subject: [PATCH] Fix ICS export section matching for non-sequential section numbers The ICS export used section.number - 1 === sectionId to match the selected section, which assumes section numbers are sequential and 1-indexed. Many Caltech courses have gaps in section numbers (e.g., 1, 2, 4, 5), causing the export to silently drop events for those courses. Fix by directly accessing the section via array index (course.courseData.sections[sectionId]), consistent with how the rest of the app resolves sections. Also adds a null-check for sectionId and a test case exercising non-sequential section numbers. Co-Authored-By: Rahul Chalamala --- src/lib/ics.test.ts | 19 +++++++++++++++++++ src/lib/ics.ts | 41 ++++++++++++++++++++--------------------- 2 files changed, 39 insertions(+), 21 deletions(-) diff --git a/src/lib/ics.test.ts b/src/lib/ics.test.ts index ea7d583..db45b74 100644 --- a/src/lib/ics.test.ts +++ b/src/lib/ics.test.ts @@ -128,4 +128,23 @@ describe("exportICS", () => { const eventCount = (ics.match(/BEGIN:VEVENT/g) || []).length; expect(eventCount).toBe(1); }); + + it("correctly exports section when section numbers have gaps", () => { + // Simulate real Caltech data where section numbers skip (e.g., 1, 2, 4, 5) + const section1 = makeSection(1, "M 09:00 - 09:55", "Baxter 101"); + const section2 = makeSection(2, "T 10:00 - 10:55", "Baxter 102"); + const section4 = makeSection(4, "W 11:00 - 11:55", "Sloan 151"); + const section5 = makeSection(5, "R 13:00 - 13:55", "Sloan 152"); + const courseData = makeCourseData(1, "CS 1", [section1, section2, section4, section5]); + + // sectionId 2 = array index 2 = section number 4 (Wednesday) + const course = makeCourse(courseData, { sectionId: 2 }); + + const ics = exportICS("sp2026", [course]); + + expect(ics).toContain("BEGIN:VEVENT"); + const eventCount = (ics.match(/BEGIN:VEVENT/g) || []).length; + expect(eventCount).toBe(1); + expect(ics).toContain("LOCATION:Sloan 151"); + }); }); diff --git a/src/lib/ics.ts b/src/lib/ics.ts index 0779ac7..1caf0a4 100644 --- a/src/lib/ics.ts +++ b/src/lib/ics.ts @@ -63,28 +63,27 @@ export function exportICS(term: string, courses: CourseStorage[]): string { // Flatten the courses and parse times with start and end times, matching locations const parsedEvents = courses - .filter(course => course.enabled) + .filter(course => course.enabled && course.sectionId !== null) .flatMap(course => { - return course.courseData.sections - .filter(section => section.number - 1 === course.sectionId) // Filter by selected section - .flatMap(section => { - const times = section.times.split('\n'); // Split multiple times on newline - const locations = section.locations.split('\n'); // Split multiple locations on newline - - // Zip times and locations together - return times.flatMap((time, index) => { - const location = locations[index] || 'Unknown'; // Match time with corresponding location - const [days, startTime, , endTime] = time.split(' '); // Separate days and time range - if (days === 'A') return []; // skip to-be-announced times - - return days.split('').map(day => ({ - name: course.courseData.number, // Use course number for the title - location, // Set the matched location for this time - startTime: getFirstOccurrence(termStartDate, day, startTime), - endTime: getFirstOccurrence(termStartDate, day, endTime) // Parse the end time - })); - }); - }); + const section = course.courseData.sections[course.sectionId!]; + if (!section) return []; + + const times = section.times.split('\n'); // Split multiple times on newline + const locations = section.locations.split('\n'); // Split multiple locations on newline + + // Zip times and locations together + return times.flatMap((time, index) => { + const location = locations[index] || 'Unknown'; // Match time with corresponding location + const [days, startTime, , endTime] = time.split(' '); // Separate days and time range + if (days === 'A') return []; // skip to-be-announced times + + return days.split('').map(day => ({ + name: course.courseData.number, // Use course number for the title + location, // Set the matched location for this time + startTime: getFirstOccurrence(termStartDate, day, startTime), + endTime: getFirstOccurrence(termStartDate, day, endTime) // Parse the end time + })); + }); }); // Create a basic ICS header using stable floating local times.