Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'fix(calendar): handle RRULE BYDAY ordinals and DST transitions' (#333) from fix/calendar-rrule-dst into main

scott 6b5e793c 7b1d7aa4

+528 -3
+79 -3
src/calendar/ics-parser.ts
··· 9 9 * - All-day events (DTSTART;VALUE=DATE) 10 10 * - SUMMARY, DESCRIPTION, DTSTART, DTEND, DURATION 11 11 * - Basic RRULE expansion (DAILY, WEEKLY, MONTHLY, YEARLY) up to a horizon 12 + * - RRULE MONTHLY+BYDAY with ordinal prefixes (e.g., 2TU = second Tuesday) 12 13 * - Folded lines (RFC 5545 §3.1) 13 14 * - Multiple VEVENT blocks in a single file 14 15 * 15 16 * Does NOT handle: VTIMEZONE definitions (uses IANA tz names from TZID), 16 - * VALARM, VTODO, VJOURNAL, or complex RRULE (BYDAY with positions, BYSETPOS). 17 + * VALARM, VTODO, VJOURNAL, or BYSETPOS. 17 18 */ 18 19 19 20 import { type CalendarEvent, EVENT_COLORS } from './helpers.js'; ··· 191 192 192 193 const EXPANSION_HORIZON_DAYS = 365; // expand recurring events up to 1 year 193 194 195 + interface ParsedByDay { 196 + ordinal: number; // 0 = no ordinal, positive = nth from start, negative = nth from end 197 + day: number; // 0=SU, 1=MO, ... 6=SA 198 + } 199 + 194 200 interface RRuleParts { 195 201 freq: string; 196 202 count?: number; 197 203 until?: Date; 198 204 interval: number; 199 205 byday?: string[]; 206 + parsedByday?: ParsedByDay[]; 200 207 } 201 208 202 209 function parseRRule(rrule: string): RRuleParts { ··· 217 224 ? new Date(parseInt(parts['UNTIL'].slice(0, 4), 10), parseInt(parts['UNTIL'].slice(4, 6), 10) - 1, parseInt(parts['UNTIL'].slice(6, 8), 10)) 218 225 : parseIcsDateTime(parts['UNTIL']); 219 226 } 220 - if (parts['BYDAY']) result.byday = parts['BYDAY'].split(','); 227 + if (parts['BYDAY']) { 228 + result.byday = parts['BYDAY'].split(','); 229 + result.parsedByday = result.byday.map(parseByDay); 230 + } 221 231 222 232 return result; 223 233 } 224 234 225 235 const DAY_MAP: Record<string, number> = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 }; 226 236 237 + /** Parse a BYDAY value like "2TU", "-1FR", "MO" into ordinal + day number */ 238 + function parseByDay(code: string): ParsedByDay { 239 + const match = code.match(/^([+-]?\d+)?([A-Z]{2})$/); 240 + if (!match) return { ordinal: 0, day: 0 }; 241 + const ordinal = match[1] ? parseInt(match[1], 10) : 0; 242 + const day = DAY_MAP[match[2]!] ?? 0; 243 + return { ordinal, day }; 244 + } 245 + 246 + /** 247 + * Find the Nth occurrence of a weekday in a given month. 248 + * ordinal > 0: count from the start (1 = first, 2 = second, ...) 249 + * ordinal < 0: count from the end (-1 = last, -2 = second-to-last, ...) 250 + * Returns null if no such occurrence exists (e.g., 5th Monday in a 4-Monday month). 251 + */ 252 + function nthWeekdayOfMonth(year: number, month: number, weekday: number, ordinal: number): Date | null { 253 + if (ordinal === 0) return null; 254 + 255 + if (ordinal > 0) { 256 + // Find first occurrence of weekday in the month 257 + const first = new Date(year, month, 1); 258 + let dayOfMonth = 1 + ((weekday - first.getDay() + 7) % 7); 259 + // Advance to the Nth occurrence 260 + dayOfMonth += (ordinal - 1) * 7; 261 + const result = new Date(year, month, dayOfMonth); 262 + // Verify we're still in the same month 263 + if (result.getMonth() !== month) return null; 264 + return result; 265 + } else { 266 + // Count from end: find last occurrence first 267 + const lastDay = new Date(year, month + 1, 0).getDate(); 268 + const lastDate = new Date(year, month, lastDay); 269 + let dayOfMonth = lastDay - ((lastDate.getDay() - weekday + 7) % 7); 270 + // Go back for negative ordinals beyond -1 271 + dayOfMonth += (ordinal + 1) * 7; 272 + const result = new Date(year, month, dayOfMonth); 273 + if (result.getMonth() !== month || dayOfMonth < 1) return null; 274 + return result; 275 + } 276 + } 277 + 227 278 function expandRRule(startDate: Date, rrule: string): Date[] { 228 279 const rule = parseRRule(rrule); 229 280 const dates: Date[] = []; ··· 255 306 } 256 307 } 257 308 cur.setDate(cur.getDate() + 7 * rule.interval); 309 + 310 + // For MONTHLY with BYDAY containing ordinals (e.g., 2TU = second Tuesday) 311 + } else if (rule.freq === 'MONTHLY' && rule.parsedByday && rule.parsedByday.length > 0 && 312 + rule.parsedByday.some(bd => bd.ordinal !== 0)) { 313 + const curYear = cur.getFullYear(); 314 + const curMonth = cur.getMonth(); 315 + 316 + for (const bd of rule.parsedByday) { 317 + const candidate = nthWeekdayOfMonth(curYear, curMonth, bd.day, bd.ordinal); 318 + if (!candidate) continue; 319 + // Preserve the original event's time 320 + candidate.setHours(startDate.getHours(), startDate.getMinutes(), startDate.getSeconds()); 321 + 322 + if (candidate >= startDate && candidate <= until && count < limit) { 323 + if (candidate.getTime() !== startDate.getTime()) { 324 + dates.push(new Date(candidate)); 325 + } 326 + count++; 327 + } 328 + } 329 + 330 + // Advance to the next month (by interval) 331 + cur.setDate(1); // avoid month overflow (e.g., Jan 31 + 1 month = Mar 3) 332 + cur.setMonth(cur.getMonth() + rule.interval); 333 + 258 334 } else { 259 335 if (cur.getTime() !== startDate.getTime()) { 260 336 dates.push(new Date(cur)); ··· 531 607 } 532 608 533 609 // Re-export for testing 534 - export { unfoldLines, parseIcsDate, parseIcsDateTime, parseDuration, expandRRule, extractVEvents, veventToCalendarEvents, convertFromTimezone }; 610 + export { unfoldLines, parseIcsDate, parseIcsDateTime, parseDuration, expandRRule, extractVEvents, veventToCalendarEvents, convertFromTimezone, nthWeekdayOfMonth, parseRRule };
+449
tests/calendar-rrule-dst.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + parseIcsFile, 4 + expandRRule, 5 + nthWeekdayOfMonth, 6 + parseRRule, 7 + parseIcsDateTime, 8 + convertFromTimezone, 9 + } from '../src/calendar/ics-parser.js'; 10 + 11 + // ========================================================================= 12 + // #561: RRULE MONTHLY+BYDAY with ordinal prefixes 13 + // ========================================================================= 14 + 15 + describe('nthWeekdayOfMonth', () => { 16 + it('finds the 1st Tuesday of April 2026', () => { 17 + // April 2026 starts on Wednesday, so 1st Tuesday is April 7 18 + const d = nthWeekdayOfMonth(2026, 3, 2, 1); // month=3 (April), day=2 (TU), ordinal=1 19 + expect(d).not.toBeNull(); 20 + expect(d!.getFullYear()).toBe(2026); 21 + expect(d!.getMonth()).toBe(3); 22 + expect(d!.getDate()).toBe(7); 23 + expect(d!.getDay()).toBe(2); // Tuesday 24 + }); 25 + 26 + it('finds the 2nd Tuesday of April 2026', () => { 27 + const d = nthWeekdayOfMonth(2026, 3, 2, 2); 28 + expect(d).not.toBeNull(); 29 + expect(d!.getDate()).toBe(14); 30 + expect(d!.getDay()).toBe(2); 31 + }); 32 + 33 + it('finds the 3rd Tuesday of April 2026', () => { 34 + const d = nthWeekdayOfMonth(2026, 3, 2, 3); 35 + expect(d).not.toBeNull(); 36 + expect(d!.getDate()).toBe(21); 37 + }); 38 + 39 + it('finds the 4th Tuesday of April 2026', () => { 40 + const d = nthWeekdayOfMonth(2026, 3, 2, 4); 41 + expect(d).not.toBeNull(); 42 + expect(d!.getDate()).toBe(28); 43 + }); 44 + 45 + it('returns null for 5th Tuesday of April 2026 (does not exist)', () => { 46 + const d = nthWeekdayOfMonth(2026, 3, 2, 5); 47 + expect(d).toBeNull(); 48 + }); 49 + 50 + it('finds the last Friday (-1) of April 2026', () => { 51 + // April 30 2026 is Thursday, so last Friday is April 24 52 + const d = nthWeekdayOfMonth(2026, 3, 5, -1); 53 + expect(d).not.toBeNull(); 54 + expect(d!.getDate()).toBe(24); 55 + expect(d!.getDay()).toBe(5); // Friday 56 + }); 57 + 58 + it('finds the last Monday (-1) of March 2026', () => { 59 + // March 2026: last day is 31 (Tuesday), so last Monday is March 30 60 + const d = nthWeekdayOfMonth(2026, 2, 1, -1); 61 + expect(d).not.toBeNull(); 62 + expect(d!.getDate()).toBe(30); 63 + expect(d!.getDay()).toBe(1); 64 + }); 65 + 66 + it('finds the second-to-last Friday (-2) of April 2026', () => { 67 + // Last Friday of April 2026 is April 24, so -2 is April 17 68 + const d = nthWeekdayOfMonth(2026, 3, 5, -2); 69 + expect(d).not.toBeNull(); 70 + expect(d!.getDate()).toBe(17); 71 + expect(d!.getDay()).toBe(5); 72 + }); 73 + 74 + it('returns null for ordinal=0', () => { 75 + const d = nthWeekdayOfMonth(2026, 3, 2, 0); 76 + expect(d).toBeNull(); 77 + }); 78 + 79 + it('finds 1st Sunday of a month starting on Sunday', () => { 80 + // March 2026 starts on Sunday 81 + const d = nthWeekdayOfMonth(2026, 2, 0, 1); 82 + expect(d).not.toBeNull(); 83 + expect(d!.getDate()).toBe(1); 84 + expect(d!.getDay()).toBe(0); 85 + }); 86 + 87 + it('finds 1st Saturday of February 2026', () => { 88 + // Feb 2026 starts Sunday, so 1st Saturday is Feb 7 89 + const d = nthWeekdayOfMonth(2026, 1, 6, 1); 90 + expect(d).not.toBeNull(); 91 + expect(d!.getDate()).toBe(7); 92 + expect(d!.getDay()).toBe(6); 93 + }); 94 + }); 95 + 96 + describe('parseRRule with BYDAY ordinals', () => { 97 + it('parses BYDAY=2TU as ordinal 2, day Tuesday', () => { 98 + const rule = parseRRule('FREQ=MONTHLY;BYDAY=2TU'); 99 + expect(rule.freq).toBe('MONTHLY'); 100 + expect(rule.parsedByday).toBeDefined(); 101 + expect(rule.parsedByday).toHaveLength(1); 102 + expect(rule.parsedByday![0].ordinal).toBe(2); 103 + expect(rule.parsedByday![0].day).toBe(2); // Tuesday 104 + }); 105 + 106 + it('parses BYDAY=-1FR as ordinal -1, day Friday', () => { 107 + const rule = parseRRule('FREQ=MONTHLY;BYDAY=-1FR'); 108 + expect(rule.parsedByday).toHaveLength(1); 109 + expect(rule.parsedByday![0].ordinal).toBe(-1); 110 + expect(rule.parsedByday![0].day).toBe(5); // Friday 111 + }); 112 + 113 + it('parses BYDAY=MO (no ordinal) as ordinal 0', () => { 114 + const rule = parseRRule('FREQ=WEEKLY;BYDAY=MO'); 115 + expect(rule.parsedByday).toHaveLength(1); 116 + expect(rule.parsedByday![0].ordinal).toBe(0); 117 + expect(rule.parsedByday![0].day).toBe(1); // Monday 118 + }); 119 + 120 + it('parses multiple BYDAY values', () => { 121 + const rule = parseRRule('FREQ=MONTHLY;BYDAY=1MO,3FR'); 122 + expect(rule.parsedByday).toHaveLength(2); 123 + expect(rule.parsedByday![0].ordinal).toBe(1); 124 + expect(rule.parsedByday![0].day).toBe(1); 125 + expect(rule.parsedByday![1].ordinal).toBe(3); 126 + expect(rule.parsedByday![1].day).toBe(5); 127 + }); 128 + }); 129 + 130 + describe('expandRRule: MONTHLY+BYDAY', () => { 131 + it('expands second Tuesday of every month (BYDAY=2TU)', () => { 132 + // Start: 2nd Tuesday of Jan 2026 = Jan 13 (Jan 1 is Thursday, 1st Tue=Jan 6, 2nd Tue=Jan 13) 133 + const start = new Date(2026, 0, 13, 10, 0, 0); // Jan 13, 2026 10:00 134 + const dates = expandRRule(start, 'FREQ=MONTHLY;BYDAY=2TU;COUNT=4'); 135 + 136 + // COUNT=4 means 4 occurrences total (including the start) 137 + // Jan 13, Feb 10, Mar 10, Apr 14 138 + expect(dates.length).toBe(3); // 3 recurrences (start is excluded from return) 139 + 140 + // February 2026: 1st Tue=Feb 3, 2nd Tue=Feb 10 141 + expect(dates[0].getMonth()).toBe(1); 142 + expect(dates[0].getDate()).toBe(10); 143 + expect(dates[0].getDay()).toBe(2); // Tuesday 144 + 145 + // March 2026: 1st Tue=Mar 3, 2nd Tue=Mar 10 146 + expect(dates[1].getMonth()).toBe(2); 147 + expect(dates[1].getDate()).toBe(10); 148 + expect(dates[1].getDay()).toBe(2); 149 + 150 + // April 2026: 1st Tue=Apr 7, 2nd Tue=Apr 14 151 + expect(dates[2].getMonth()).toBe(3); 152 + expect(dates[2].getDate()).toBe(14); 153 + expect(dates[2].getDay()).toBe(2); 154 + }); 155 + 156 + it('expands last Friday of every month (BYDAY=-1FR)', () => { 157 + // Start: last Friday of Jan 2026 = Jan 30 158 + const start = new Date(2026, 0, 30, 14, 0, 0); 159 + const dates = expandRRule(start, 'FREQ=MONTHLY;BYDAY=-1FR;COUNT=3'); 160 + 161 + expect(dates.length).toBe(2); 162 + 163 + // Feb 2026: last Friday = Feb 27 164 + expect(dates[0].getMonth()).toBe(1); 165 + expect(dates[0].getDate()).toBe(27); 166 + expect(dates[0].getDay()).toBe(5); 167 + 168 + // March 2026: last Friday = Mar 27 169 + expect(dates[1].getMonth()).toBe(2); 170 + expect(dates[1].getDate()).toBe(27); 171 + expect(dates[1].getDay()).toBe(5); 172 + }); 173 + 174 + it('preserves time of day across MONTHLY+BYDAY recurrences', () => { 175 + const start = new Date(2026, 0, 13, 14, 30, 0); // 2:30 PM 176 + const dates = expandRRule(start, 'FREQ=MONTHLY;BYDAY=2TU;COUNT=3'); 177 + 178 + for (const d of dates) { 179 + expect(d.getHours()).toBe(14); 180 + expect(d.getMinutes()).toBe(30); 181 + } 182 + }); 183 + 184 + it('handles MONTHLY+BYDAY with INTERVAL=2 (every other month)', () => { 185 + const start = new Date(2026, 0, 13, 10, 0, 0); // 2nd Tue Jan 2026 186 + const dates = expandRRule(start, 'FREQ=MONTHLY;BYDAY=2TU;INTERVAL=2;COUNT=3'); 187 + 188 + expect(dates.length).toBe(2); 189 + 190 + // Skips Feb, lands on March: 2nd Tue of Mar 2026 = Mar 10 191 + expect(dates[0].getMonth()).toBe(2); 192 + expect(dates[0].getDate()).toBe(10); 193 + 194 + // Skips Apr, lands on May: 2nd Tue of May 2026 = May 12 195 + expect(dates[1].getMonth()).toBe(4); 196 + expect(dates[1].getDate()).toBe(12); 197 + expect(dates[1].getDay()).toBe(2); 198 + }); 199 + }); 200 + 201 + describe('parseIcsFile: MONTHLY+BYDAY integration', () => { 202 + it('parses ICS with RRULE:FREQ=MONTHLY;BYDAY=2TU', () => { 203 + const ics = [ 204 + 'BEGIN:VCALENDAR', 205 + 'BEGIN:VEVENT', 206 + 'DTSTART:20260113T100000', 207 + 'DTEND:20260113T110000', 208 + 'SUMMARY:Team Standup', 209 + 'RRULE:FREQ=MONTHLY;BYDAY=2TU;COUNT=4', 210 + 'END:VEVENT', 211 + 'END:VCALENDAR', 212 + ].join('\r\n'); 213 + 214 + const result = parseIcsFile(ics); 215 + expect(result.errors).toHaveLength(0); 216 + expect(result.sourceEventCount).toBe(1); 217 + // 1 base + 3 recurrences = 4 total 218 + expect(result.events).toHaveLength(4); 219 + 220 + const dates = result.events.map(e => e.date); 221 + expect(dates).toContain('2026-01-13'); // base 222 + expect(dates).toContain('2026-02-10'); // 2nd Tue Feb 223 + expect(dates).toContain('2026-03-10'); // 2nd Tue Mar 224 + expect(dates).toContain('2026-04-14'); // 2nd Tue Apr 225 + 226 + // All should preserve start/end times 227 + for (const evt of result.events) { 228 + expect(evt.startTime).toBe('10:00'); 229 + expect(evt.endTime).toBe('11:00'); 230 + } 231 + }); 232 + 233 + it('parses ICS with RRULE:FREQ=MONTHLY;BYDAY=-1FR (last Friday)', () => { 234 + const ics = [ 235 + 'BEGIN:VCALENDAR', 236 + 'BEGIN:VEVENT', 237 + 'DTSTART;VALUE=DATE:20260130', 238 + 'SUMMARY:Payday', 239 + 'RRULE:FREQ=MONTHLY;BYDAY=-1FR;COUNT=3', 240 + 'END:VEVENT', 241 + 'END:VCALENDAR', 242 + ].join('\r\n'); 243 + 244 + const result = parseIcsFile(ics); 245 + expect(result.errors).toHaveLength(0); 246 + expect(result.events).toHaveLength(3); 247 + 248 + const dates = result.events.map(e => e.date); 249 + expect(dates).toContain('2026-01-30'); 250 + expect(dates).toContain('2026-02-27'); 251 + expect(dates).toContain('2026-03-27'); 252 + }); 253 + }); 254 + 255 + // ========================================================================= 256 + // #521: DST transition and timezone handling tests 257 + // ========================================================================= 258 + 259 + describe('DST transition handling', () => { 260 + // US Spring Forward: March 8, 2026 at 2:00 AM -> 3:00 AM (America/Los_Angeles) 261 + // US Fall Back: November 1, 2026 at 2:00 AM -> 1:00 AM (America/Los_Angeles) 262 + 263 + it('daily recurrence across spring-forward DST boundary preserves wall-clock time', () => { 264 + // Event at 10:00 AM on March 7, recurring daily for 3 days 265 + // March 7 (before DST), March 8 (DST day), March 9 (after DST) 266 + const start = new Date(2026, 2, 7, 10, 0, 0); 267 + const dates = expandRRule(start, 'FREQ=DAILY;COUNT=3'); 268 + 269 + // Should have 2 recurrences (start excluded from returned array) 270 + expect(dates.length).toBe(2); 271 + 272 + // Both should still be at 10:00 local time (not shifted by DST) 273 + expect(dates[0].getDate()).toBe(8); 274 + expect(dates[0].getHours()).toBe(10); 275 + expect(dates[0].getMinutes()).toBe(0); 276 + 277 + expect(dates[1].getDate()).toBe(9); 278 + expect(dates[1].getHours()).toBe(10); 279 + expect(dates[1].getMinutes()).toBe(0); 280 + }); 281 + 282 + it('daily recurrence across fall-back DST boundary preserves wall-clock time', () => { 283 + // Event at 10:00 AM on Oct 31, recurring daily for 3 days 284 + // Oct 31 (before fall-back), Nov 1 (fall-back day), Nov 2 (after) 285 + const start = new Date(2026, 9, 31, 10, 0, 0); 286 + const dates = expandRRule(start, 'FREQ=DAILY;COUNT=3'); 287 + 288 + expect(dates.length).toBe(2); 289 + 290 + expect(dates[0].getMonth()).toBe(10); // November 291 + expect(dates[0].getDate()).toBe(1); 292 + expect(dates[0].getHours()).toBe(10); 293 + 294 + expect(dates[1].getMonth()).toBe(10); 295 + expect(dates[1].getDate()).toBe(2); 296 + expect(dates[1].getHours()).toBe(10); 297 + }); 298 + 299 + it('weekly recurrence across spring-forward preserves wall-clock time', () => { 300 + // Start: Saturday March 7 2026, 9:00 AM, weekly for 3 weeks 301 + const start = new Date(2026, 2, 7, 9, 0, 0); 302 + const dates = expandRRule(start, 'FREQ=WEEKLY;COUNT=3'); 303 + 304 + expect(dates.length).toBe(2); 305 + 306 + // March 14 (after DST change) 307 + expect(dates[0].getDate()).toBe(14); 308 + expect(dates[0].getHours()).toBe(9); 309 + 310 + // March 21 311 + expect(dates[1].getDate()).toBe(21); 312 + expect(dates[1].getHours()).toBe(9); 313 + }); 314 + 315 + it('monthly recurrence across spring-forward preserves wall-clock time', () => { 316 + // Start: Feb 15 2026, 10:30 AM, monthly for 3 months 317 + const start = new Date(2026, 1, 15, 10, 30, 0); 318 + const dates = expandRRule(start, 'FREQ=MONTHLY;COUNT=3'); 319 + 320 + expect(dates.length).toBe(2); 321 + 322 + // March 15 (after DST) 323 + expect(dates[0].getMonth()).toBe(2); 324 + expect(dates[0].getDate()).toBe(15); 325 + expect(dates[0].getHours()).toBe(10); 326 + expect(dates[0].getMinutes()).toBe(30); 327 + 328 + // April 15 329 + expect(dates[1].getMonth()).toBe(3); 330 + expect(dates[1].getDate()).toBe(15); 331 + expect(dates[1].getHours()).toBe(10); 332 + expect(dates[1].getMinutes()).toBe(30); 333 + }); 334 + 335 + it('MONTHLY+BYDAY across spring-forward preserves wall-clock time', () => { 336 + // 2nd Tuesday starting Jan 2026 at 2:30 AM (a time that could be affected by DST) 337 + const start = new Date(2026, 0, 13, 2, 30, 0); 338 + const dates = expandRRule(start, 'FREQ=MONTHLY;BYDAY=2TU;COUNT=4'); 339 + 340 + expect(dates.length).toBe(3); 341 + 342 + // All recurrences should have time 2:30 AM 343 + for (const d of dates) { 344 + expect(d.getHours()).toBe(2); 345 + expect(d.getMinutes()).toBe(30); 346 + expect(d.getDay()).toBe(2); // All should be Tuesdays 347 + } 348 + }); 349 + 350 + it('event spanning DST transition via ICS import has correct duration', () => { 351 + // Event on March 7 2026 at 11:00 PM to March 8 at 1:00 AM (spans midnight + DST) 352 + const ics = [ 353 + 'BEGIN:VCALENDAR', 354 + 'BEGIN:VEVENT', 355 + 'DTSTART:20260307T230000', 356 + 'DTEND:20260308T010000', 357 + 'SUMMARY:Late Night Event', 358 + 'END:VEVENT', 359 + 'END:VCALENDAR', 360 + ].join('\r\n'); 361 + 362 + const result = parseIcsFile(ics); 363 + expect(result.errors).toHaveLength(0); 364 + expect(result.events).toHaveLength(1); 365 + expect(result.events[0].startTime).toBe('23:00'); 366 + expect(result.events[0].endTime).toBe('01:00'); 367 + }); 368 + 369 + it('ICS with TZID across DST boundary converts correctly', () => { 370 + // Event in America/New_York timezone during spring forward 371 + // March 8 2026 at 10:00 AM ET 372 + const ics = [ 373 + 'BEGIN:VCALENDAR', 374 + 'BEGIN:VEVENT', 375 + 'DTSTART;TZID=America/New_York:20260308T100000', 376 + 'DTEND;TZID=America/New_York:20260308T110000', 377 + 'SUMMARY:East Coast Meeting', 378 + 'END:VEVENT', 379 + 'END:VCALENDAR', 380 + ].join('\r\n'); 381 + 382 + const result = parseIcsFile(ics); 383 + expect(result.errors).toHaveLength(0); 384 + expect(result.events).toHaveLength(1); 385 + // The event should parse without error and have valid times 386 + expect(result.events[0].title).toBe('East Coast Meeting'); 387 + expect(result.events[0].startTime).toBeTruthy(); 388 + expect(result.events[0].endTime).toBeTruthy(); 389 + }); 390 + 391 + it('yearly recurrence across DST preserves wall-clock time', () => { 392 + // Start: March 10 2026 at 1:30 AM (just after spring forward) 393 + // expandRRule uses a horizon of now + 365 days, so only the next year is within range 394 + const start = new Date(2026, 2, 10, 1, 30, 0); 395 + const dates = expandRRule(start, 'FREQ=YEARLY;COUNT=3'); 396 + 397 + // Only 1 recurrence within the 365-day horizon (March 10 2027) 398 + expect(dates.length).toBeGreaterThanOrEqual(1); 399 + 400 + // March 10 2027 401 + expect(dates[0].getFullYear()).toBe(2027); 402 + expect(dates[0].getMonth()).toBe(2); 403 + expect(dates[0].getDate()).toBe(10); 404 + expect(dates[0].getHours()).toBe(1); 405 + expect(dates[0].getMinutes()).toBe(30); 406 + }); 407 + }); 408 + 409 + describe('convertFromTimezone DST awareness', () => { 410 + it('converts Eastern time before spring forward correctly', () => { 411 + // March 7 2026, 10:00 AM ET (EST, UTC-5) 412 + const naive = new Date(2026, 2, 7, 10, 0, 0); 413 + const converted = convertFromTimezone(naive, 'America/New_York'); 414 + // Should produce a valid date (not NaN) 415 + expect(converted.getTime()).not.toBeNaN(); 416 + }); 417 + 418 + it('converts Eastern time after spring forward correctly', () => { 419 + // March 9 2026, 10:00 AM ET (EDT, UTC-4) 420 + const naive = new Date(2026, 2, 9, 10, 0, 0); 421 + const converted = convertFromTimezone(naive, 'America/New_York'); 422 + expect(converted.getTime()).not.toBeNaN(); 423 + }); 424 + 425 + it('handles ambiguous time during fall-back gracefully', () => { 426 + // Nov 1 2026, 1:30 AM ET could be EST or EDT (ambiguous during fall-back) 427 + const naive = new Date(2026, 10, 1, 1, 30, 0); 428 + const converted = convertFromTimezone(naive, 'America/New_York'); 429 + // Should not crash; should produce a valid date 430 + expect(converted.getTime()).not.toBeNaN(); 431 + }); 432 + 433 + it('handles invalid/skipped time during spring-forward gracefully', () => { 434 + // March 8 2026, 2:30 AM ET does not exist (clocks jump from 2:00 to 3:00) 435 + const naive = new Date(2026, 2, 8, 2, 30, 0); 436 + const converted = convertFromTimezone(naive, 'America/New_York'); 437 + // Should not crash; should produce a valid date 438 + expect(converted.getTime()).not.toBeNaN(); 439 + }); 440 + 441 + it('returns date as-is for unrecognized timezone', () => { 442 + const naive = new Date(2026, 2, 8, 10, 0, 0); 443 + const converted = convertFromTimezone(naive, 'Invalid/Timezone'); 444 + // Should fallback to returning the input as-is 445 + expect(converted.getFullYear()).toBe(2026); 446 + expect(converted.getMonth()).toBe(2); 447 + expect(converted.getDate()).toBe(8); 448 + }); 449 + });