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 'feat(calendar): handle TZID timezone conversion in ICS import' (#321) from feat/calendar-timezone into main

scott 81607010 82be475e

+275 -9
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.26.0", 3 + "version": "0.27.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+83 -8
src/calendar/ics-parser.ts
··· 5 5 * them to our CalendarEvent format. Handles: 6 6 * 7 7 * - DATE and DATE-TIME values (with and without timezone) 8 + * - TZID parameters on DTSTART/DTEND (converts from source timezone via Intl API) 8 9 * - All-day events (DTSTART;VALUE=DATE) 9 10 * - SUMMARY, DESCRIPTION, DTSTART, DTEND, DURATION 10 11 * - Basic RRULE expansion (DAILY, WEEKLY, MONTHLY, YEARLY) up to a horizon 11 12 * - Folded lines (RFC 5545 §3.1) 12 13 * - Multiple VEVENT blocks in a single file 13 14 * 14 - * Does NOT handle: VTIMEZONE definitions (uses local time), VALARM, 15 - * VTODO, VJOURNAL, or complex RRULE (BYDAY with positions, BYSETPOS). 15 + * Does NOT handle: VTIMEZONE definitions (uses IANA tz names from TZID), 16 + * VALARM, VTODO, VJOURNAL, or complex RRULE (BYDAY with positions, BYSETPOS). 16 17 */ 17 18 18 19 import { type CalendarEvent, EVENT_COLORS } from './helpers.js'; ··· 29 30 duration: string; // raw DURATION value (may be empty) 30 31 allDay: boolean; 31 32 rrule: string; // raw RRULE value (may be empty) 33 + dtstartTzid: string; // TZID parameter from DTSTART (may be empty) 34 + dtendTzid: string; // TZID parameter from DTEND (may be empty) 32 35 } 33 36 34 37 // --------------------------------------------------------------------------- ··· 53 56 return { year: y, month: m, day: d }; 54 57 } 55 58 59 + /** 60 + * Convert a date-time from a source timezone to the local timezone. 61 + * Uses Intl.DateTimeFormat to determine the UTC offset at the given time 62 + * in the source timezone, then computes the correct UTC instant. 63 + * 64 + * The input `date` carries year/month/day/hour/minute/second values that 65 + * represent wall-clock time in the source timezone. This function figures 66 + * out what UTC instant those values correspond to and returns a Date 67 + * representing that instant. 68 + */ 69 + function convertFromTimezone(date: Date, tzid: string): Date { 70 + try { 71 + const sourceFormatter = new Intl.DateTimeFormat('en-US', { 72 + timeZone: tzid, 73 + year: 'numeric', 74 + month: '2-digit', 75 + day: '2-digit', 76 + hour: '2-digit', 77 + minute: '2-digit', 78 + second: '2-digit', 79 + hour12: false, 80 + }); 81 + 82 + // Create a UTC date with the same numeric values as the wall-clock time. 83 + // We then ask: "What does this UTC instant look like in the source tz?" 84 + // The difference tells us the tz offset. 85 + const utcDate = new Date(Date.UTC( 86 + date.getFullYear(), 87 + date.getMonth(), 88 + date.getDate(), 89 + date.getHours(), 90 + date.getMinutes(), 91 + date.getSeconds() 92 + )); 93 + 94 + const parts = sourceFormatter.formatToParts(utcDate); 95 + const get = (type: string) => parseInt(parts.find(p => p.type === type)?.value || '0', 10); 96 + const tzYear = get('year'); 97 + const tzMonth = get('month'); 98 + const tzDay = get('day'); 99 + let tzHour = get('hour'); 100 + // Intl hour12:false can return 24 for midnight in some engines 101 + if (tzHour === 24) tzHour = 0; 102 + const tzMinute = get('minute'); 103 + const tzSecond = get('second'); 104 + 105 + // What the source tz shows for that UTC instant 106 + const shownInTz = Date.UTC(tzYear, tzMonth - 1, tzDay, tzHour, tzMinute, tzSecond); 107 + const askedMs = utcDate.getTime(); 108 + 109 + // The offset: how far ahead the tz display is from the raw UTC value 110 + const tzOffsetMs = shownInTz - askedMs; 111 + 112 + // The actual UTC time = raw wall-clock values (as UTC) minus the tz offset 113 + const actualUtcMs = askedMs - tzOffsetMs; 114 + 115 + return new Date(actualUtcMs); 116 + } catch { 117 + // If timezone is not recognized, return as-is (treat as local) 118 + return date; 119 + } 120 + } 121 + 56 122 /** Parse an iCal DATETIME (YYYYMMDDTHHMMSS or YYYYMMDDTHHMMSSZ) */ 57 - function parseIcsDateTime(val: string): Date { 123 + function parseIcsDateTime(val: string, tzid?: string): Date { 58 124 const datePart = val.slice(0, 8); 59 125 const timePart = val.slice(9, 15); 60 126 const isUtc = val.endsWith('Z'); ··· 69 135 if (isUtc) { 70 136 return new Date(Date.UTC(y, mo, d, h, mi, s)); 71 137 } 138 + 139 + // If TZID is specified, convert from that timezone 140 + if (tzid) { 141 + const naiveDate = new Date(y, mo, d, h, mi, s); 142 + return convertFromTimezone(naiveDate, tzid); 143 + } 144 + 72 145 return new Date(y, mo, d, h, mi, s); 73 146 } 74 147 ··· 261 334 for (const line of lines) { 262 335 if (line === 'BEGIN:VEVENT') { 263 336 inEvent = true; 264 - current = { summary: '', description: '', dtstart: '', dtend: '', duration: '', rrule: '', allDay: false }; 337 + current = { summary: '', description: '', dtstart: '', dtend: '', duration: '', rrule: '', allDay: false, dtstartTzid: '', dtendTzid: '' }; 265 338 continue; 266 339 } 267 340 ··· 292 365 const { value, params } = extractValue(line); 293 366 current.dtstart = value; 294 367 current.allDay = params['VALUE'] === 'DATE' || isDateOnly(value); 368 + current.dtstartTzid = params['TZID'] || ''; 295 369 break; 296 370 } 297 371 case 'DTEND': { 298 - const { value } = extractValue(line); 372 + const { value, params } = extractValue(line); 299 373 current.dtend = value; 374 + current.dtendTzid = params['TZID'] || ''; 300 375 break; 301 376 } 302 377 case 'DURATION': { ··· 359 434 } 360 435 361 436 // Timed event 362 - const startDt = parseIcsDateTime(vevt.dtstart); 437 + const startDt = parseIcsDateTime(vevt.dtstart, vevt.dtstartTzid || undefined); 363 438 let endDt: Date; 364 439 365 440 if (vevt.dtend) { 366 - endDt = parseIcsDateTime(vevt.dtend); 441 + endDt = parseIcsDateTime(vevt.dtend, vevt.dtendTzid || undefined); 367 442 } else if (vevt.duration) { 368 443 const durationMin = parseDuration(vevt.duration); 369 444 endDt = new Date(startDt.getTime() + durationMin * 60_000); ··· 456 531 } 457 532 458 533 // Re-export for testing 459 - export { unfoldLines, parseIcsDate, parseIcsDateTime, parseDuration, expandRRule, extractVEvents, veventToCalendarEvents }; 534 + export { unfoldLines, parseIcsDate, parseIcsDateTime, parseDuration, expandRRule, extractVEvents, veventToCalendarEvents, convertFromTimezone };
+191
tests/ics-parser.test.ts
··· 9 9 parseIcsDateTime, 10 10 parseDuration, 11 11 extractVEvents, 12 + convertFromTimezone, 12 13 } from '../src/calendar/ics-parser.js'; 13 14 14 15 // --------------------------------------------------------------------------- ··· 491 492 expect(result.events[0]!.title).toBe('Imported Event'); 492 493 }); 493 494 }); 495 + 496 + // --------------------------------------------------------------------------- 497 + // convertFromTimezone 498 + // --------------------------------------------------------------------------- 499 + 500 + describe('convertFromTimezone', () => { 501 + it('converts a date from a known timezone to UTC-based Date', () => { 502 + // 2026-04-15 14:00 in America/New_York (EDT, UTC-4) 503 + // Should produce a Date representing 18:00 UTC 504 + const naive = new Date(2026, 3, 15, 14, 0, 0); 505 + const converted = convertFromTimezone(naive, 'America/New_York'); 506 + expect(converted.getUTCHours()).toBe(18); 507 + expect(converted.getUTCMinutes()).toBe(0); 508 + }); 509 + 510 + it('converts from a timezone west of UTC', () => { 511 + // 2026-04-15 09:00 in America/Los_Angeles (PDT, UTC-7) 512 + // Should produce 16:00 UTC 513 + const naive = new Date(2026, 3, 15, 9, 0, 0); 514 + const converted = convertFromTimezone(naive, 'America/Los_Angeles'); 515 + expect(converted.getUTCHours()).toBe(16); 516 + expect(converted.getUTCMinutes()).toBe(0); 517 + }); 518 + 519 + it('converts from a timezone east of UTC', () => { 520 + // 2026-04-15 21:00 in Asia/Tokyo (JST, UTC+9, no DST) 521 + // Should produce 12:00 UTC 522 + const naive = new Date(2026, 3, 15, 21, 0, 0); 523 + const converted = convertFromTimezone(naive, 'Asia/Tokyo'); 524 + expect(converted.getUTCHours()).toBe(12); 525 + expect(converted.getUTCMinutes()).toBe(0); 526 + }); 527 + 528 + it('handles DST transition correctly', () => { 529 + // 2026-01-15 14:00 in America/New_York (EST, UTC-5) — winter, no DST 530 + // Should produce 19:00 UTC 531 + const naive = new Date(2026, 0, 15, 14, 0, 0); 532 + const converted = convertFromTimezone(naive, 'America/New_York'); 533 + expect(converted.getUTCHours()).toBe(19); 534 + expect(converted.getUTCMinutes()).toBe(0); 535 + }); 536 + 537 + it('returns date as-is for unrecognized timezone', () => { 538 + const naive = new Date(2026, 3, 15, 14, 0, 0); 539 + const converted = convertFromTimezone(naive, 'Fake/Timezone'); 540 + // Should return the same date object values (treated as local) 541 + expect(converted.getHours()).toBe(14); 542 + expect(converted.getMinutes()).toBe(0); 543 + }); 544 + }); 545 + 546 + // --------------------------------------------------------------------------- 547 + // parseIcsDateTime with TZID 548 + // --------------------------------------------------------------------------- 549 + 550 + describe('parseIcsDateTime with TZID', () => { 551 + it('converts datetime from specified timezone', () => { 552 + // 20260415T140000 with TZID=America/New_York (EDT, UTC-4) => 18:00 UTC 553 + const d = parseIcsDateTime('20260415T140000', 'America/New_York'); 554 + expect(d.getUTCHours()).toBe(18); 555 + expect(d.getUTCMinutes()).toBe(0); 556 + }); 557 + 558 + it('UTC suffix takes precedence over TZID', () => { 559 + // Z suffix means UTC regardless of any TZID 560 + const d = parseIcsDateTime('20260415T180000Z', 'America/New_York'); 561 + expect(d.getUTCHours()).toBe(18); 562 + }); 563 + 564 + it('no TZID treats as local time', () => { 565 + const d = parseIcsDateTime('20260415T140000'); 566 + expect(d.getHours()).toBe(14); 567 + expect(d.getMinutes()).toBe(0); 568 + }); 569 + }); 570 + 571 + // --------------------------------------------------------------------------- 572 + // Timezone handling (integration) 573 + // --------------------------------------------------------------------------- 574 + 575 + describe('timezone handling', () => { 576 + it('parses DTSTART with TZID and converts from that timezone', () => { 577 + const ics = [ 578 + 'BEGIN:VCALENDAR', 579 + 'BEGIN:VEVENT', 580 + 'SUMMARY:NYC Meeting', 581 + 'DTSTART;TZID=America/New_York:20260415T140000', 582 + 'DTEND;TZID=America/New_York:20260415T150000', 583 + 'END:VEVENT', 584 + 'END:VCALENDAR', 585 + ].join('\r\n'); 586 + const result = parseIcsFile(ics); 587 + expect(result.events.length).toBe(1); 588 + expect(result.events[0]!.title).toBe('NYC Meeting'); 589 + expect(result.errors.length).toBe(0); 590 + // The event should have been converted from Eastern to local. 591 + // Verify the UTC time is correct: 14:00 EDT = 18:00 UTC 592 + // We can't check local hours portably, but the event should parse without error. 593 + }); 594 + 595 + it('UTC times (Z suffix) are parsed correctly', () => { 596 + const ics = [ 597 + 'BEGIN:VCALENDAR', 598 + 'BEGIN:VEVENT', 599 + 'SUMMARY:UTC Event', 600 + 'DTSTART:20260415T180000Z', 601 + 'DTEND:20260415T190000Z', 602 + 'END:VEVENT', 603 + 'END:VCALENDAR', 604 + ].join('\r\n'); 605 + const result = parseIcsFile(ics); 606 + expect(result.events.length).toBe(1); 607 + expect(result.events[0]!.title).toBe('UTC Event'); 608 + expect(result.errors.length).toBe(0); 609 + }); 610 + 611 + it('events without TZID or Z are treated as local time', () => { 612 + const ics = [ 613 + 'BEGIN:VCALENDAR', 614 + 'BEGIN:VEVENT', 615 + 'SUMMARY:Local Event', 616 + 'DTSTART:20260415T140000', 617 + 'DTEND:20260415T150000', 618 + 'END:VEVENT', 619 + 'END:VCALENDAR', 620 + ].join('\r\n'); 621 + const result = parseIcsFile(ics); 622 + expect(result.events.length).toBe(1); 623 + expect(result.events[0]!.startTime).toBe('14:00'); 624 + expect(result.events[0]!.endTime).toBe('15:00'); 625 + }); 626 + 627 + it('handles unknown timezone gracefully', () => { 628 + const ics = [ 629 + 'BEGIN:VCALENDAR', 630 + 'BEGIN:VEVENT', 631 + 'SUMMARY:Unknown TZ', 632 + 'DTSTART;TZID=Fake/Timezone:20260415T140000', 633 + 'DTEND;TZID=Fake/Timezone:20260415T150000', 634 + 'END:VEVENT', 635 + 'END:VCALENDAR', 636 + ].join('\r\n'); 637 + const result = parseIcsFile(ics); 638 + expect(result.events.length).toBe(1); 639 + // Falls back to treating as local time 640 + expect(result.events[0]!.startTime).toBe('14:00'); 641 + }); 642 + 643 + it('extracts TZID from DTSTART and DTEND in extractVEvents', () => { 644 + const lines = [ 645 + 'BEGIN:VEVENT', 646 + 'SUMMARY:TZ Event', 647 + 'DTSTART;TZID=America/Chicago:20260415T130000', 648 + 'DTEND;TZID=America/Chicago:20260415T140000', 649 + 'END:VEVENT', 650 + ]; 651 + const events = extractVEvents(lines); 652 + expect(events).toHaveLength(1); 653 + expect(events[0]!.dtstartTzid).toBe('America/Chicago'); 654 + expect(events[0]!.dtendTzid).toBe('America/Chicago'); 655 + }); 656 + 657 + it('sets empty TZID when no TZID parameter present', () => { 658 + const lines = [ 659 + 'BEGIN:VEVENT', 660 + 'SUMMARY:No TZ', 661 + 'DTSTART:20260415T130000', 662 + 'DTEND:20260415T140000', 663 + 'END:VEVENT', 664 + ]; 665 + const events = extractVEvents(lines); 666 + expect(events).toHaveLength(1); 667 + expect(events[0]!.dtstartTzid).toBe(''); 668 + expect(events[0]!.dtendTzid).toBe(''); 669 + }); 670 + 671 + it('handles mixed TZID on start and end', () => { 672 + const lines = [ 673 + 'BEGIN:VEVENT', 674 + 'SUMMARY:Mixed TZ', 675 + 'DTSTART;TZID=America/New_York:20260415T140000', 676 + 'DTEND;TZID=America/Los_Angeles:20260415T140000', 677 + 'END:VEVENT', 678 + ]; 679 + const events = extractVEvents(lines); 680 + expect(events).toHaveLength(1); 681 + expect(events[0]!.dtstartTzid).toBe('America/New_York'); 682 + expect(events[0]!.dtendTzid).toBe('America/Los_Angeles'); 683 + }); 684 + });