From b9bebeb13988ac375c3dd3b7a77f9ae8352fd83c Mon Sep 17 00:00:00 2001 From: Finn Date: Mon, 23 Mar 2026 15:52:51 +0000 Subject: [PATCH 01/42] fix(calendar): use toJSDate() for proper timezone conversion --- src/app/calendar-app/calendar-app.component.spec.ts | 11 ++++++++--- src/app/calendar-app/calendar.service.spec.ts | 6 ++++-- src/app/calendar-app/runbox-calendar-event.spec.ts | 8 +++++--- src/app/calendar-app/runbox-calendar-event.ts | 13 ++++++------- 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/app/calendar-app/calendar-app.component.spec.ts b/src/app/calendar-app/calendar-app.component.spec.ts index e2b29af55..99211c514 100644 --- a/src/app/calendar-app/calendar-app.component.spec.ts +++ b/src/app/calendar-app/calendar-app.component.spec.ts @@ -305,8 +305,13 @@ END:VCALENDAR events = fixture.debugElement.nativeElement.querySelectorAll('button.calendarMonthDayEvent'); expect(component.shown_events.length).toBeGreaterThan(2, 'more events should be displayed now'); const first_occurence = component.shown_events[0].start; - expect(first_occurence.getDate()).toBe(1, 'day matches'); - expect(first_occurence.getHours()).toBe(12, 'hour matches'); - expect(first_occurence.getMinutes()).toBe(34, 'minute matches'); + // Event was created at 12:34 UTC using moment.utc().date(1).hour(12).minute(34).toISOString() + // but without a TZID in the iCal data. The calendar timezone is Europe/Stockholm (UTC+1). + // With the fix to use toJSDate(), the time is now properly converted: + // 12:34 Stockholm time = 11:34 UTC + // Check the date parts separately to avoid issues with seconds/milliseconds + expect(first_occurence.getUTCDate()).toBe(1); + expect(first_occurence.getUTCHours()).toBe(11); + expect(first_occurence.getUTCMinutes()).toBe(34); }); }); diff --git a/src/app/calendar-app/calendar.service.spec.ts b/src/app/calendar-app/calendar.service.spec.ts index 6fbfe662e..bd6f64def 100644 --- a/src/app/calendar-app/calendar.service.spec.ts +++ b/src/app/calendar-app/calendar.service.spec.ts @@ -340,8 +340,10 @@ END:VCALENDAR // Produces multiple CalendarEvents which refer to the same ICal.Event expect(rbevents.length).toEqual(5, 'Recurring event contains 5 instances'); expect(rbevents[0].recurringFrequency).toEqual('DAILY', 'recurrence is DAILY'); - expect(rbevents[0].start).toEqual(new Date(2021, 3, 25, 15, 0, 0), 'event 1 start date is 3pm in Stockholm'); - expect(rbevents[1].start).toEqual(new Date(2021, 3, 26, 16, 0, 0), 'event 1 start date is 4pm in Stockholm'); + // Event 1: 9am Eastern (EDT UTC-4) = 13:00 UTC + // Event 2: 10am Eastern (EDT UTC-4) = 14:00 UTC (exception with +1hr) + expect(rbevents[0].start).toEqual(moment.utc('2021-04-25T13:00:00').toDate(), 'event 1 start date is 9am Eastern = 13:00 UTC'); + expect(rbevents[1].start).toEqual(moment.utc('2021-04-26T14:00:00').toDate(), 'event 2 start date is 10am Eastern = 14:00 UTC'); }); it('should be possible to import a static (non recurring) event', () => { diff --git a/src/app/calendar-app/runbox-calendar-event.spec.ts b/src/app/calendar-app/runbox-calendar-event.spec.ts index 7ce53cb4a..61c895d40 100644 --- a/src/app/calendar-app/runbox-calendar-event.spec.ts +++ b/src/app/calendar-app/runbox-calendar-event.spec.ts @@ -349,10 +349,12 @@ END:VCALENDAR` ); console.log('sut tz :' + sut.timezone); // verify timezone event has sane start/end dates - // This should be in the user's tz (Europe/London in this test) + // Event is at 09:00 Berlin (CEST=UTC+2) = 07:00 UTC + // When converted to New York (EDT=UTC-4) = 03:00 local NY time + // toJSDate() returns the correct absolute UTC time console.log('event start :' + sut.start.toISOString()); - // 3am New York - expect(sut.start.toISOString()).toBe('2021-05-15T03:00:00.000Z'); + // 07:00 UTC (which is 03:00 New York EDT) + expect(sut.start.toISOString()).toBe('2021-05-15T07:00:00.000Z'); // Move this one an hour later // TZ? const future = moment('2021-05-15T04:00:00'); diff --git a/src/app/calendar-app/runbox-calendar-event.ts b/src/app/calendar-app/runbox-calendar-event.ts index 81da09446..4769b9813 100644 --- a/src/app/calendar-app/runbox-calendar-event.ts +++ b/src/app/calendar-app/runbox-calendar-event.ts @@ -145,15 +145,13 @@ export class RunboxCalendarEvent implements CalendarEvent { let user_dtstart = this._dtstart; // can't convert items with no tz set, so assume default (utc) if (this._dtstart.zone) { - // console.log('start: convert from: ' + user_dtstart.zone.tzid); - // console.log('offset: ' + user_dtstart.zone.utcOffset(user_dtstart)); - // console.log('start: convert to : ' + this.timezone); - // console.log('have timezone? : ' + ICAL.TimezoneService.has(this.timezone)); - // console.log('offset: ' + ICAL.TimezoneService.get(this.timezone).utcOffset(user_dtstart)); user_dtstart = this._dtstart.convertToZone(ICAL.TimezoneService.get(this.timezone)); } - return new Date(user_dtstart.toString()); + // Use toJSDate() to properly convert ICAL.Time to JavaScript Date + // This correctly handles timezone conversion rather than parsing + // the string as local browser time (which new Date(string) does) + return user_dtstart.toJSDate(); } get end(): Date { @@ -172,7 +170,8 @@ export class RunboxCalendarEvent implements CalendarEvent { shownEnd = shownEnd.convertToZone(ICAL.TimezoneService.get(this.timezone)); } - return new Date(shownEnd.toString()); + // Use toJSDate() to properly convert ICAL.Time to JavaScript Date + return shownEnd.toJSDate(); } set allDay(value) { From 49c145efe2df1a62de500e27e2a3cbb40fdb4708 Mon Sep 17 00:00:00 2001 From: Finn Date: Tue, 24 Mar 2026 13:40:54 +0000 Subject: [PATCH 02/42] fix(calendar): handle floating time events in user's timezone --- .../runbox-calendar-event.spec.ts | 155 ++++++++++++++++++ src/app/calendar-app/runbox-calendar-event.ts | 24 ++- 2 files changed, 176 insertions(+), 3 deletions(-) diff --git a/src/app/calendar-app/runbox-calendar-event.spec.ts b/src/app/calendar-app/runbox-calendar-event.spec.ts index 61c895d40..b43810dc2 100644 --- a/src/app/calendar-app/runbox-calendar-event.spec.ts +++ b/src/app/calendar-app/runbox-calendar-event.spec.ts @@ -377,4 +377,159 @@ END:VCALENDAR` expect(sut.toIcal()).toContain('RECURRENCE-ID;TZID=/freeassociation.sourceforge.net/Europe/Berlin:20210515T\r\n 090000'); expect(sut.toIcal()).toContain('DTSTART;TZID=/freeassociation.sourceforge.net/Europe/Berlin:20210515T100000'); }); + + it('should handle floating time events (no TZID) by interpreting in user timezone', () => { + // Setup: Register Europe/London and Europe/Berlin timezones + const londonTzData = `BEGIN:VTIMEZONE +TZID:Europe/London +X-LIC-LOCATION:Europe/London +BEGIN:STANDARD +TZNAME:GMT +DTSTART:19701025T020000 +TZOFFSETFROM:+0100 +TZOFFSETTO:+0000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +BEGIN:DAYLIGHT +TZNAME:BST +DTSTART:19810329T010000 +TZOFFSETFROM:+0000 +TZOFFSETTO:+0100 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +END:VTIMEZONE`; + + const berlinTzData = `BEGIN:VTIMEZONE +TZID:Europe/Berlin +X-LIC-LOCATION:Europe/Berlin +BEGIN:STANDARD +TZNAME:CET +DTSTART:19701025T020000 +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +BEGIN:DAYLIGHT +TZNAME:CEST +DTSTART:19810329T010000 +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +END:VTIMEZONE`; + + // Register timezones + const londonComponent = new ICAL.Component(ICAL.parse(londonTzData)); + const londonTz = new ICAL.Timezone({ + tzid: londonComponent.getFirstPropertyValue('tzid'), + component: londonComponent + }); + ICAL.TimezoneService.register(londonTz.tzid, londonTz); + + const berlinComponent = new ICAL.Component(ICAL.parse(berlinTzData)); + const berlinTz = new ICAL.Timezone({ + tzid: berlinComponent.getFirstPropertyValue('tzid'), + component: berlinComponent + }); + ICAL.TimezoneService.register(berlinTz.tzid, berlinTz); + + // Create a floating time event (no TZID) - represents 4pm local time regardless of timezone + // This simulates an event created without specifying a timezone + const floatingEvent = new RunboxCalendarEvent( + 'testcal/floating', + new ICAL.Event(new ICAL.Component(['vevent', [ + ['dtstart', {}, 'date', '2026-03-24T16:00:00'], + ['dtend', {}, 'date', '2026-03-24T17:00:00'], + ['summary', {}, 'text', 'Floating event at 4pm'], + ]])), + ICAL.Time.fromDateTimeString('2026-03-24T16:00:00'), + ICAL.Time.fromDateTimeString('2026-03-24T17:00:00'), + 'Europe/London' // User's account timezone + ); + + // The floating time should be interpreted as 4pm in the user's timezone (London) + // In March, 2026, London is GMT (UTC+0), so 4pm London = 4pm UTC + const startResult = floatingEvent.start; + + // Expected: 4pm London (user's tz) = 4pm UTC + // The fix ensures floating times are interpreted in user's timezone, not browser's + expect(startResult.getUTCHours()).toBe(16, 'Floating time 4pm London should be 4pm UTC'); + + // Now test with user timezone as Berlin + const floatingEventBerlin = new RunboxCalendarEvent( + 'testcal/floating-berlin', + new ICAL.Event(new ICAL.Component(['vevent', [ + ['dtstart', {}, 'date', '2026-03-24T16:00:00'], + ['dtend', {}, 'date', '2026-03-24T17:00:00'], + ['summary', {}, 'text', 'Floating event at 4pm (Berlin user)'], + ]])), + ICAL.Time.fromDateTimeString('2026-03-24T16:00:00'), + ICAL.Time.fromDateTimeString('2026-03-24T17:00:00'), + 'Europe/Berlin' // User's account timezone is now Berlin + ); + + const startResultBerlin = floatingEventBerlin.start; + + // Expected: 4pm Berlin (user's tz) = 3pm UTC (Berlin is CET = UTC+1) + // With the fix, floating times are interpreted in user's timezone + expect(startResultBerlin.getUTCHours()).toBe(15, 'Floating time 4pm Berlin should be 3pm UTC'); + }); + + // Helper to create and register a timezone from standard offsets + function ensureTimezone(tzid: string, stdOffset: number, dstOffset: number) { + if (ICAL.TimezoneService.has(tzid)) { return; } + const std = String(stdOffset).padStart(2, '0'); + const dst = String(dstOffset).padStart(2, '0'); + const tzData = `BEGIN:VTIMEZONE +TZID:${tzid} +BEGIN:STANDARD +DTSTART:19701025T020000 +TZOFFSETFROM:+${dst}00 +TZOFFSETTO:+${std}00 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19810329T010000 +TZOFFSETFROM:+${std}00 +TZOFFSETTO:+${dst}00 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +END:VTIMEZONE`; + const comp = new ICAL.Component(ICAL.parse(tzData)); + ICAL.TimezoneService.register(tzid, new ICAL.Timezone({ tzid, component: comp })); + } + + it('should correctly convert London TZID event to Berlin user timezone', () => { + // Simulates an issue: 4pm London event shown as 3pm (wrong) instead of 5pm (correct) + ensureTimezone('Europe/London', 0, 1); // GMT/BST + ensureTimezone('Europe/Berlin', 1, 2); // CET/CEST + + // Create event with TZID=Europe/London at 4pm (March = GMT = UTC+0) + const vevent = new ICAL.Component(['vevent', [ + ['dtstart', { tzid: 'Europe/London' }, 'date', '2026-03-24T16:00:00'], + ['dtend', { tzid: 'Europe/London' }, 'date', '2026-03-24T17:00:00'], + ['summary', {}, 'text', 'Meeting at 4pm London'], + ]]); + const dtstartProp = vevent.getFirstProperty('dtstart'); + + const londonEvent = new RunboxCalendarEvent( + 'testcal/lon', + new ICAL.Event(vevent), + ICAL.Time.fromDateTimeString('2026-03-24T16:00:00', dtstartProp), + ICAL.Time.fromDateTimeString('2026-03-24T17:00:00', dtstartProp), + 'Europe/London' + ); + expect(londonEvent.start.getUTCHours()).toBe(16, '4pm London = 16:00 UTC'); + + const berlinUserEvent = new RunboxCalendarEvent( + 'testcal/ber', + new ICAL.Event(vevent), + ICAL.Time.fromDateTimeString('2026-03-24T16:00:00', dtstartProp), + ICAL.Time.fromDateTimeString('2026-03-24T17:00:00', dtstartProp), + 'Europe/Berlin' + ); + // 4pm London (GMT+0) = 4pm UTC = 5pm Berlin (CET+1) when displayed + // The UTC time must remain 16:00Z - angular-calendar displays in browser's local tz + expect(berlinUserEvent.start.getUTCHours()).toBe(16, 'Same event = same UTC time (16:00Z)'); + }); }); diff --git a/src/app/calendar-app/runbox-calendar-event.ts b/src/app/calendar-app/runbox-calendar-event.ts index 4769b9813..34bc2b894 100644 --- a/src/app/calendar-app/runbox-calendar-event.ts +++ b/src/app/calendar-app/runbox-calendar-event.ts @@ -143,9 +143,18 @@ export class RunboxCalendarEvent implements CalendarEvent { // This needs to be converted *from* tz the ical data is in // *to* the tz the user's calendar display is in (this.timezone?) let user_dtstart = this._dtstart; - // can't convert items with no tz set, so assume default (utc) + if (this._dtstart.zone) { - user_dtstart = this._dtstart.convertToZone(ICAL.TimezoneService.get(this.timezone)); + // Event has a timezone - convert to user's display timezone + const targetTz = ICAL.TimezoneService.get(this.timezone); + if (targetTz) { + user_dtstart = this._dtstart.convertToZone(targetTz); + } + } else if (this.timezone && ICAL.TimezoneService.has(this.timezone)) { + // Floating time (no TZID) - interpret in user's account timezone + // This fixes the issue where floating times were interpreted in browser timezone + const userTz = ICAL.TimezoneService.get(this.timezone); + user_dtstart = this._dtstart.convertToZone(userTz); } // Use toJSDate() to properly convert ICAL.Time to JavaScript Date @@ -166,8 +175,17 @@ export class RunboxCalendarEvent implements CalendarEvent { } else { shownEnd.addDuration(new ICAL.Duration({'isNegative': true, 'seconds': 1})); } + if (shownEnd.zone) { - shownEnd = shownEnd.convertToZone(ICAL.TimezoneService.get(this.timezone)); + // Event has a timezone - convert to user's display timezone + const targetTz = ICAL.TimezoneService.get(this.timezone); + if (targetTz) { + shownEnd = shownEnd.convertToZone(targetTz); + } + } else if (this.timezone && ICAL.TimezoneService.has(this.timezone)) { + // Floating time (no TZID) - interpret in user's account timezone + const userTz = ICAL.TimezoneService.get(this.timezone); + shownEnd = shownEnd.convertToZone(userTz); } // Use toJSDate() to properly convert ICAL.Time to JavaScript Date From 3219cd33b34f17cdb449be2dabbc3b8c4dc56be5 Mon Sep 17 00:00:00 2001 From: Finn Date: Tue, 24 Mar 2026 17:34:42 +0000 Subject: [PATCH 03/42] fix(calendar): changes made to datetime stuff, should be more correct based on timezone calendar etc. now --- .../runbox-calendar-event.spec.ts | 123 ++++++++++++++---- src/app/calendar-app/runbox-calendar-event.ts | 78 ++++++----- 2 files changed, 146 insertions(+), 55 deletions(-) diff --git a/src/app/calendar-app/runbox-calendar-event.spec.ts b/src/app/calendar-app/runbox-calendar-event.spec.ts index b43810dc2..2d027445d 100644 --- a/src/app/calendar-app/runbox-calendar-event.spec.ts +++ b/src/app/calendar-app/runbox-calendar-event.spec.ts @@ -33,7 +33,6 @@ describe('RunboxCalendarEvent', () => { newEvent.title = 'New Event'; newEvent.location = 'Somewhere'; - // test things addEvent calls: expect(newEvent.toIcal()).toMatch(/BEGIN:VEVENT/); }); it('should be possible to create a new event, without times', () => { @@ -55,9 +54,7 @@ describe('RunboxCalendarEvent', () => { [] ); - // test things addEvent calls: expect(newEvent.toIcal()).toMatch(/BEGIN:VEVENT/); - // check date not time: const now = ICAL.Time.fromJSDate(moment().date(1).toDate()); expect(newEvent.toIcal()).not.toContain(now.toICALString()); now.isDate = true; @@ -71,8 +68,8 @@ describe('RunboxCalendarEvent', () => { [ 'dtend', {}, 'date', moment().toISOString().split('T')[0] ], [ 'summary', {}, 'text', 'One-time event' ], ] ] - ]])), ICAL.Time.fromJSDate(new Date()), ICAL.Time.fromJSDate(new Date()) - , 'Europe/London' // user's timezone for display + ]])), ICAL.Time.fromJSDate(new Date()), ICAL.Time.fromJSDate(new Date()), + 'Europe/London' ); sut.recurringFrequency = 'WEEKLY'; expect(sut.recurringFrequency).toBe('WEEKLY', 'recurrence seems to be set'); @@ -116,8 +113,8 @@ describe('RunboxCalendarEvent', () => { [ 'dtend', {}, 'date', moment().toISOString().split('T')[0] ], [ 'summary', {}, 'text', 'One-time event' ], ] ] - ]])), ICAL.Time.fromJSDate(new Date()), ICAL.Time.fromJSDate(new Date()) - , 'Europe/London' // user's timezone for display + ]])), ICAL.Time.fromJSDate(new Date()), ICAL.Time.fromJSDate(new Date()), + 'Europe/London' ); sut.recurringFrequency = 'MONTHLY'; sut.recurInterval = 1; // the default @@ -433,8 +430,7 @@ END:VTIMEZONE`; }); ICAL.TimezoneService.register(berlinTz.tzid, berlinTz); - // Create a floating time event (no TZID) - represents 4pm local time regardless of timezone - // This simulates an event created without specifying a timezone + // Floating time (no TZID) - interpreted in calendar's timezone const floatingEvent = new RunboxCalendarEvent( 'testcal/floating', new ICAL.Event(new ICAL.Component(['vevent', [ @@ -444,18 +440,13 @@ END:VTIMEZONE`; ]])), ICAL.Time.fromDateTimeString('2026-03-24T16:00:00'), ICAL.Time.fromDateTimeString('2026-03-24T17:00:00'), - 'Europe/London' // User's account timezone + 'Europe/London' ); - // The floating time should be interpreted as 4pm in the user's timezone (London) - // In March, 2026, London is GMT (UTC+0), so 4pm London = 4pm UTC - const startResult = floatingEvent.start; - - // Expected: 4pm London (user's tz) = 4pm UTC - // The fix ensures floating times are interpreted in user's timezone, not browser's - expect(startResult.getUTCHours()).toBe(16, 'Floating time 4pm London should be 4pm UTC'); + // 16:00 floating with calendar tz London (UTC+0) = 16:00 UTC + expect(floatingEvent.start.getUTCHours()).toBe(16, 'Floating time 16:00 London should be 16:00 UTC'); - // Now test with user timezone as Berlin + // Same floating time for Berlin user - interpreted as Berlin time (UTC+1) = 15:00 UTC const floatingEventBerlin = new RunboxCalendarEvent( 'testcal/floating-berlin', new ICAL.Event(new ICAL.Component(['vevent', [ @@ -465,14 +456,10 @@ END:VTIMEZONE`; ]])), ICAL.Time.fromDateTimeString('2026-03-24T16:00:00'), ICAL.Time.fromDateTimeString('2026-03-24T17:00:00'), - 'Europe/Berlin' // User's account timezone is now Berlin + 'Europe/Berlin' ); - const startResultBerlin = floatingEventBerlin.start; - - // Expected: 4pm Berlin (user's tz) = 3pm UTC (Berlin is CET = UTC+1) - // With the fix, floating times are interpreted in user's timezone - expect(startResultBerlin.getUTCHours()).toBe(15, 'Floating time 4pm Berlin should be 3pm UTC'); + expect(floatingEventBerlin.start.getUTCHours()).toBe(15, 'Floating time 16:00 Berlin should be 15:00 UTC'); }); // Helper to create and register a timezone from standard offsets @@ -532,4 +519,92 @@ END:VTIMEZONE`; // The UTC time must remain 16:00Z - angular-calendar displays in browser's local tz expect(berlinUserEvent.start.getUTCHours()).toBe(16, 'Same event = same UTC time (16:00Z)'); }); + + it('should handle event with TZID but timezone NOT registered', () => { + // Bug: Event has TZID=Europe/London but London isn't in TimezoneService + // Fix: Preserve local time values instead of misinterpreting as floating + ensureTimezone('Europe/Berlin', 1, 2); + + const vevent = new ICAL.Component(['vevent', [ + ['dtstart', { tzid: 'Europe/London' }, 'date', '2026-03-24T16:00:00'], + ['dtend', { tzid: 'Europe/London' }, 'date', '2026-03-24T17:00:00'], + ['summary', {}, 'text', 'Meeting at 4pm London'], + ]]); + const dtstartProp = vevent.getFirstProperty('dtstart'); + const dtstart = ICAL.Time.fromDateTimeString('2026-03-24T16:00:00', dtstartProp); + const dtend = ICAL.Time.fromDateTimeString('2026-03-24T17:00:00', dtstartProp); + + const event = new RunboxCalendarEvent( + 'testcal/bug', + new ICAL.Event(vevent), + dtstart, + dtend, + 'Europe/Berlin' + ); + + expect(event.start.getUTCHours()).toBe(16, '4pm London should be 16:00 UTC even if London not registered'); + }); + + it('should display 4pm London event as 5pm for Berlin user', () => { + // Bug: 4pm London showed as 3pm Berlin instead of 5pm + ensureTimezone('Europe/London', 0, 1); + ensureTimezone('Europe/Berlin', 1, 2); + + const vevent = new ICAL.Component(['vevent', [ + ['dtstart', { tzid: 'Europe/London' }, 'date', '2026-03-24T16:00:00'], + ['dtend', { tzid: 'Europe/London' }, 'date', '2026-03-24T17:00:00'], + ['summary', {}, 'text', 'Meeting at 4pm London'], + ]]); + const dtstartProp = vevent.getFirstProperty('dtstart'); + const dtstart = ICAL.Time.fromDateTimeString('2026-03-24T16:00:00', dtstartProp); + const dtend = ICAL.Time.fromDateTimeString('2026-03-24T17:00:00', dtstartProp); + + const event = new RunboxCalendarEvent( + 'testcal/berlin-user', + new ICAL.Event(vevent), + dtstart, + dtend, + 'Europe/Berlin' + ); + + // Month view: event.start.getUTCHours() + expect(event.start.getUTCHours()).toBe(16, '4pm London = 16:00 UTC'); + // Event card: event.dtstart (moment with timezone) + expect(event.dtstart.hour()).toBe(17, '4pm London displays as 5pm (17:00) in Berlin'); + }); + + it('should handle TZID path mismatch between event and user timezone', () => { + // User's tz may be registered with full path (e.g., /citadel.org/.../Europe/Berlin) + // while event uses simple TZID (e.g., Europe/London) + ensureTimezone('/citadel.org/20210210_1/Europe/Berlin', 1, 2); + ensureTimezone('Europe/Berlin', 1, 2); + ensureTimezone('Europe/London', 0, 1); + + const vevent = new ICAL.Component(['vevent', [ + ['dtstart', { tzid: 'Europe/London' }, 'date', '2026-03-24T16:00:00'], + ['dtend', { tzid: 'Europe/London' }, 'date', '2026-03-24T17:00:00'], + ['summary', {}, 'text', 'Meeting at 4pm London'], + ]]); + const dtstartProp = vevent.getFirstProperty('dtstart'); + const dtstart = ICAL.Time.fromDateTimeString('2026-03-24T16:00:00', dtstartProp); + + const event = new RunboxCalendarEvent( + 'testcal/tzpath', + new ICAL.Event(vevent), + dtstart, + ICAL.Time.fromDateTimeString('2026-03-24T17:00:00', dtstartProp), + '/citadel.org/20210210_1/Europe/Berlin' + ); + + expect(event.start.getUTCHours()).toBe(16, '4pm London should be 16:00 UTC'); + + const eventSimple = new RunboxCalendarEvent( + 'testcal/tzsimple', + new ICAL.Event(vevent), + ICAL.Time.fromDateTimeString('2026-03-24T16:00:00', dtstartProp), + ICAL.Time.fromDateTimeString('2026-03-24T17:00:00', dtstartProp), + 'Europe/Berlin' + ); + expect(eventSimple.start.getUTCHours()).toBe(16, 'Simple tz name should also work'); + }); }); diff --git a/src/app/calendar-app/runbox-calendar-event.ts b/src/app/calendar-app/runbox-calendar-event.ts index 34bc2b894..5bc201121 100644 --- a/src/app/calendar-app/runbox-calendar-event.ts +++ b/src/app/calendar-app/runbox-calendar-event.ts @@ -140,27 +140,7 @@ export class RunboxCalendarEvent implements CalendarEvent { // angular-calendar compatibility get start(): Date { - // This needs to be converted *from* tz the ical data is in - // *to* the tz the user's calendar display is in (this.timezone?) - let user_dtstart = this._dtstart; - - if (this._dtstart.zone) { - // Event has a timezone - convert to user's display timezone - const targetTz = ICAL.TimezoneService.get(this.timezone); - if (targetTz) { - user_dtstart = this._dtstart.convertToZone(targetTz); - } - } else if (this.timezone && ICAL.TimezoneService.has(this.timezone)) { - // Floating time (no TZID) - interpret in user's account timezone - // This fixes the issue where floating times were interpreted in browser timezone - const userTz = ICAL.TimezoneService.get(this.timezone); - user_dtstart = this._dtstart.convertToZone(userTz); - } - - // Use toJSDate() to properly convert ICAL.Time to JavaScript Date - // This correctly handles timezone conversion rather than parsing - // the string as local browser time (which new Date(string) does) - return user_dtstart.toJSDate(); + return this.convertIcalTimeToDate(this._dtstart, 'dtstart'); } get end(): Date { @@ -168,7 +148,7 @@ export class RunboxCalendarEvent implements CalendarEvent { return undefined; } - let shownEnd = this._dtend.clone(); + const shownEnd = this._dtend.clone(); // ICAL event DTEND is exclusive, angular-calendar is inclusive if (this.allDay) { shownEnd.addDuration(new ICAL.Duration({'isNegative': true, 'days': 1})); @@ -176,20 +156,56 @@ export class RunboxCalendarEvent implements CalendarEvent { shownEnd.addDuration(new ICAL.Duration({'isNegative': true, 'seconds': 1})); } - if (shownEnd.zone) { - // Event has a timezone - convert to user's display timezone + return this.convertIcalTimeToDate(shownEnd, 'dtend'); + } + + /** + * Convert an ICAL.Time to a JavaScript Date with proper timezone handling. + * + * Handles three cases: + * 1. Proper timezone (has VTIMEZONE data or UTC) → convert to user's display timezone + * 2. True floating time (no TZID in property) → interpret in calendar's timezone + * 3. Unresolved TZID (has TZID but not found) → preserve local time values as UTC + */ + private convertIcalTimeToDate(time: ICAL.Time, propName: string): Date { + const zone = time.zone; + // Check for proper timezone with VTIMEZONE data or UTC + const hasProperTimezone = zone && + typeof zone === 'object' && + zone.tzid && + zone.tzid !== 'floating' && + (zone.component || zone.tzid === 'UTC'); + + if (hasProperTimezone) { const targetTz = ICAL.TimezoneService.get(this.timezone); if (targetTz) { - shownEnd = shownEnd.convertToZone(targetTz); + time = time.convertToZone(targetTz); + } + return time.toJSDate(); + } + + // Check if the property had a TZID parameter to differentiate floating vs unresolved + const prop = this.event.component.getFirstProperty(propName); + const hasTzidParam = prop && prop.getParameter('tzid'); + + if (!hasTzidParam) { + // True floating time - interpret in calendar's timezone + const calendarTz = ICAL.TimezoneService.get(this.timezone); + if (calendarTz) { + time = time.convertToZone(calendarTz); + return time.toJSDate(); } - } else if (this.timezone && ICAL.TimezoneService.has(this.timezone)) { - // Floating time (no TZID) - interpret in user's account timezone - const userTz = ICAL.TimezoneService.get(this.timezone); - shownEnd = shownEnd.convertToZone(userTz); } - // Use toJSDate() to properly convert ICAL.Time to JavaScript Date - return shownEnd.toJSDate(); + // Unresolved TZID or no calendar timezone - preserve local time values + return new Date(Date.UTC( + time.year, + time.month - 1, + time.day, + time.hour, + time.minute, + time.second + )); } set allDay(value) { From d07433847c1b40c77038aff150800435c643ffc3 Mon Sep 17 00:00:00 2001 From: Finn Date: Wed, 25 Mar 2026 17:21:43 +0000 Subject: [PATCH 04/42] fix(calendar): improve floating time handling and fix test jCal format --- .../runbox-calendar-event.spec.ts | 46 ++++++++++--------- src/app/calendar-app/runbox-calendar-event.ts | 36 ++++++++++++++- 2 files changed, 58 insertions(+), 24 deletions(-) diff --git a/src/app/calendar-app/runbox-calendar-event.spec.ts b/src/app/calendar-app/runbox-calendar-event.spec.ts index 2d027445d..41b6d2f1d 100644 --- a/src/app/calendar-app/runbox-calendar-event.spec.ts +++ b/src/app/calendar-app/runbox-calendar-event.spec.ts @@ -431,15 +431,16 @@ END:VTIMEZONE`; ICAL.TimezoneService.register(berlinTz.tzid, berlinTz); // Floating time (no TZID) - interpreted in calendar's timezone + const floatingIcalEvent = new ICAL.Event(new ICAL.Component(['vevent', [ + ['dtstart', {}, 'date-time', '2026-03-24T16:00:00'], + ['dtend', {}, 'date-time', '2026-03-24T17:00:00'], + ['summary', {}, 'text', 'Floating event at 4pm'], + ]])); const floatingEvent = new RunboxCalendarEvent( 'testcal/floating', - new ICAL.Event(new ICAL.Component(['vevent', [ - ['dtstart', {}, 'date', '2026-03-24T16:00:00'], - ['dtend', {}, 'date', '2026-03-24T17:00:00'], - ['summary', {}, 'text', 'Floating event at 4pm'], - ]])), - ICAL.Time.fromDateTimeString('2026-03-24T16:00:00'), - ICAL.Time.fromDateTimeString('2026-03-24T17:00:00'), + floatingIcalEvent, + floatingIcalEvent.startDate, + floatingIcalEvent.endDate, 'Europe/London' ); @@ -447,15 +448,16 @@ END:VTIMEZONE`; expect(floatingEvent.start.getUTCHours()).toBe(16, 'Floating time 16:00 London should be 16:00 UTC'); // Same floating time for Berlin user - interpreted as Berlin time (UTC+1) = 15:00 UTC + const floatingBerlinIcalEvent = new ICAL.Event(new ICAL.Component(['vevent', [ + ['dtstart', {}, 'date-time', '2026-03-24T16:00:00'], + ['dtend', {}, 'date-time', '2026-03-24T17:00:00'], + ['summary', {}, 'text', 'Floating event at 4pm (Berlin user)'], + ]])); const floatingEventBerlin = new RunboxCalendarEvent( 'testcal/floating-berlin', - new ICAL.Event(new ICAL.Component(['vevent', [ - ['dtstart', {}, 'date', '2026-03-24T16:00:00'], - ['dtend', {}, 'date', '2026-03-24T17:00:00'], - ['summary', {}, 'text', 'Floating event at 4pm (Berlin user)'], - ]])), - ICAL.Time.fromDateTimeString('2026-03-24T16:00:00'), - ICAL.Time.fromDateTimeString('2026-03-24T17:00:00'), + floatingBerlinIcalEvent, + floatingBerlinIcalEvent.startDate, + floatingBerlinIcalEvent.endDate, 'Europe/Berlin' ); @@ -493,8 +495,8 @@ END:VTIMEZONE`; // Create event with TZID=Europe/London at 4pm (March = GMT = UTC+0) const vevent = new ICAL.Component(['vevent', [ - ['dtstart', { tzid: 'Europe/London' }, 'date', '2026-03-24T16:00:00'], - ['dtend', { tzid: 'Europe/London' }, 'date', '2026-03-24T17:00:00'], + ['dtstart', { tzid: 'Europe/London' }, 'date-time', '2026-03-24T16:00:00'], + ['dtend', { tzid: 'Europe/London' }, 'date-time', '2026-03-24T17:00:00'], ['summary', {}, 'text', 'Meeting at 4pm London'], ]]); const dtstartProp = vevent.getFirstProperty('dtstart'); @@ -526,8 +528,8 @@ END:VTIMEZONE`; ensureTimezone('Europe/Berlin', 1, 2); const vevent = new ICAL.Component(['vevent', [ - ['dtstart', { tzid: 'Europe/London' }, 'date', '2026-03-24T16:00:00'], - ['dtend', { tzid: 'Europe/London' }, 'date', '2026-03-24T17:00:00'], + ['dtstart', { tzid: 'Europe/London' }, 'date-time', '2026-03-24T16:00:00'], + ['dtend', { tzid: 'Europe/London' }, 'date-time', '2026-03-24T17:00:00'], ['summary', {}, 'text', 'Meeting at 4pm London'], ]]); const dtstartProp = vevent.getFirstProperty('dtstart'); @@ -551,8 +553,8 @@ END:VTIMEZONE`; ensureTimezone('Europe/Berlin', 1, 2); const vevent = new ICAL.Component(['vevent', [ - ['dtstart', { tzid: 'Europe/London' }, 'date', '2026-03-24T16:00:00'], - ['dtend', { tzid: 'Europe/London' }, 'date', '2026-03-24T17:00:00'], + ['dtstart', { tzid: 'Europe/London' }, 'date-time', '2026-03-24T16:00:00'], + ['dtend', { tzid: 'Europe/London' }, 'date-time', '2026-03-24T17:00:00'], ['summary', {}, 'text', 'Meeting at 4pm London'], ]]); const dtstartProp = vevent.getFirstProperty('dtstart'); @@ -581,8 +583,8 @@ END:VTIMEZONE`; ensureTimezone('Europe/London', 0, 1); const vevent = new ICAL.Component(['vevent', [ - ['dtstart', { tzid: 'Europe/London' }, 'date', '2026-03-24T16:00:00'], - ['dtend', { tzid: 'Europe/London' }, 'date', '2026-03-24T17:00:00'], + ['dtstart', { tzid: 'Europe/London' }, 'date-time', '2026-03-24T16:00:00'], + ['dtend', { tzid: 'Europe/London' }, 'date-time', '2026-03-24T17:00:00'], ['summary', {}, 'text', 'Meeting at 4pm London'], ]]); const dtstartProp = vevent.getFirstProperty('dtstart'); diff --git a/src/app/calendar-app/runbox-calendar-event.ts b/src/app/calendar-app/runbox-calendar-event.ts index 5bc201121..58f1f8902 100644 --- a/src/app/calendar-app/runbox-calendar-event.ts +++ b/src/app/calendar-app/runbox-calendar-event.ts @@ -190,14 +190,46 @@ export class RunboxCalendarEvent implements CalendarEvent { if (!hasTzidParam) { // True floating time - interpret in calendar's timezone + // First try ICAL.TimezoneService (for non-standard paths with VTIMEZONE data) const calendarTz = ICAL.TimezoneService.get(this.timezone); + if (calendarTz) { - time = time.convertToZone(calendarTz); - return time.toJSDate(); + // Create time directly in calendar's timezone, then convert to UTC via toJSDate() + const localTime = new ICAL.Time({ + year: time.year, + month: time.month, + day: time.day, + hour: time.hour, + minute: time.minute, + second: time.second + }, calendarTz); + return localTime.toJSDate(); + } + + // Try moment-timezone for standard IANA timezones + const momentZone = moment.tz.zone(this.timezone); + if (momentZone) { + const m = moment.tz([ + time.year, + time.month - 1, + time.day, + time.hour, + time.minute, + time.second, + 0 + ], this.timezone); + return m.toDate(); } + + // Fallback: no calendar timezone available, preserve local time as UTC + return this.icalTimeToUTCDate(time); } // Unresolved TZID or no calendar timezone - preserve local time values + return this.icalTimeToUTCDate(time); + } + + private icalTimeToUTCDate(time: ICAL.Time): Date { return new Date(Date.UTC( time.year, time.month - 1, From 8333fd910ec448736a7fb7580f90dc51be4cbf27 Mon Sep 17 00:00:00 2001 From: Finn Date: Wed, 1 Apr 2026 15:44:47 +0100 Subject: [PATCH 05/42] fix(calendar): make GH-181 test DST-aware with dynamic Stockholm offset --- src/app/calendar-app/calendar-app.component.spec.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/app/calendar-app/calendar-app.component.spec.ts b/src/app/calendar-app/calendar-app.component.spec.ts index 99211c514..1a22bdd78 100644 --- a/src/app/calendar-app/calendar-app.component.spec.ts +++ b/src/app/calendar-app/calendar-app.component.spec.ts @@ -306,12 +306,14 @@ END:VCALENDAR expect(component.shown_events.length).toBeGreaterThan(2, 'more events should be displayed now'); const first_occurence = component.shown_events[0].start; // Event was created at 12:34 UTC using moment.utc().date(1).hour(12).minute(34).toISOString() - // but without a TZID in the iCal data. The calendar timezone is Europe/Stockholm (UTC+1). - // With the fix to use toJSDate(), the time is now properly converted: - // 12:34 Stockholm time = 11:34 UTC - // Check the date parts separately to avoid issues with seconds/milliseconds + // but without a TZID in the iCal data. The calendar timezone is Europe/Stockholm. + // The floating time 12:34 is interpreted as Stockholm local time. + // Stockholm offset depends on DST: CET (UTC+1) in winter, CEST (UTC+2) in summer. + const eventLocalHour = 12; + const stockholmOffset = moment.tz('Europe/Stockholm').utcOffset(); // minutes + const expectedUtcHour = (eventLocalHour - stockholmOffset / 60 + 24) % 24; expect(first_occurence.getUTCDate()).toBe(1); - expect(first_occurence.getUTCHours()).toBe(11); + expect(first_occurence.getUTCHours()).toBe(expectedUtcHour); expect(first_occurence.getUTCMinutes()).toBe(34); }); }); From bc35b3dc8d38c9be3030e973af6d348c72bffc1e Mon Sep 17 00:00:00 2001 From: Finn Date: Wed, 1 Apr 2026 19:21:05 +0100 Subject: [PATCH 06/42] test(calendar): add E2E tests for timezone handling Add Cypress E2E tests covering floating time, UTC, TZID, all-day, recurring, and citadel-path TZID event imports. Add mockserver endpoint for ICS calendar import and event reset for test isolation. --- e2e/cypress/integration/calendar-timezone.ts | 191 +++++++++++++++++++ e2e/mockserver/mockserver.ts | 30 +++ 2 files changed, 221 insertions(+) create mode 100644 e2e/cypress/integration/calendar-timezone.ts diff --git a/e2e/cypress/integration/calendar-timezone.ts b/e2e/cypress/integration/calendar-timezone.ts new file mode 100644 index 000000000..f896ffc8b --- /dev/null +++ b/e2e/cypress/integration/calendar-timezone.ts @@ -0,0 +1,191 @@ +/// +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2016-2026 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + +function futureDateStr(daysFromNow: number): string { + const d = new Date(); + d.setDate(d.getDate() + daysFromNow); + return d.toISOString().replace(/-/g, '').replace(/T.*/, ''); +} + +function buildIcs(veventBlocks: string[], vtimezone?: string): string { + const parts = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Runbox//E2E Test//EN', + ]; + if (vtimezone) { parts.push(vtimezone); } + parts.push(...veventBlocks, 'END:VCALENDAR'); + return parts.join('\r\n'); +} + +function makeVevent(dtstart: string, dtend: string, summary: string, uid: string, extra: string[] = []): string { + return [ + 'BEGIN:VEVENT', + `DTSTART:${dtstart}`, + `DTEND:${dtend}`, + `SUMMARY:${summary}`, + `UID:${uid}`, + 'DTSTAMP:20260101T000000Z', + ...extra, + 'END:VEVENT', + ].join('\r\n'); +} + +const osloVtimezone = [ + 'BEGIN:VTIMEZONE', + 'TZID:/citadel.org/20210210_1/Europe/Oslo', + 'LAST-MODIFIED:20210210T123706Z', + 'X-LIC-LOCATION:Europe/Oslo', + 'BEGIN:STANDARD', + 'TZNAME:CET', + 'TZOFFSETFROM:+0200', + 'TZOFFSETTO:+0100', + 'DTSTART:19961027T030000', + 'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU', + 'END:STANDARD', + 'BEGIN:DAYLIGHT', + 'TZNAME:CEST', + 'TZOFFSETFROM:+0100', + 'TZOFFSETTO:+0200', + 'DTSTART:19810329T020000', + 'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU', + 'END:DAYLIGHT', + 'END:VTIMEZONE', +].join('\r\n'); + +describe('Calendar timezone handling', () => { + beforeEach(() => { + cy.request('/rest/e2e/resetCalendarEvents'); + cy.visit('/calendar'); + cy.get('.calendarListItem').should('have.length', 1).and('contain', 'Mock Calendar'); + }); + + function selectIcs(ics: string) { + cy.get('input[type=file]').selectFile({ + contents: Cypress.Buffer.from(ics), + fileName: 'test.ics', + mimeType: 'text/calendar', + }, { force: true }); + } + + function doImport() { + cy.get('mat-select').click(); + cy.contains('mat-option', 'Mock Calendar').click(); + cy.contains('button', 'Import events').click(); + cy.get('simple-snack-bar').should('contain', 'events imported'); + } + + it('should display floating time event in import preview', () => { + const dateStr = futureDateStr(15); + selectIcs(buildIcs([ + makeVevent(`${dateStr}T140000`, `${dateStr}T150000`, 'Floating Time Meeting', 'tztest-floating-001', + ['LOCATION:Oslo Office']), + ])); + cy.get('app-calendar-event-card .upcomingEventCard') + .should('contain', 'Floating Time Meeting') + .and('contain', 'Oslo Office'); + }); + + it('should display UTC event in import preview', () => { + const dateStr = futureDateStr(15); + selectIcs(buildIcs([ + makeVevent(`${dateStr}T130000Z`, `${dateStr}T140000Z`, 'UTC Meeting', 'tztest-utc-001'), + ])); + cy.get('app-calendar-event-card .upcomingEventCard') + .should('contain', 'UTC Meeting'); + }); + + it('should display TZID event in import preview', () => { + const dateStr = futureDateStr(15); + selectIcs(buildIcs([ + makeVevent(`TZID=Europe/Oslo:${dateStr}T140000`, `TZID=Europe/Oslo:${dateStr}T150000`, + 'TZID Oslo Meeting', 'tztest-tzid-001'), + ])); + cy.get('app-calendar-event-card .upcomingEventCard') + .should('contain', 'TZID Oslo Meeting'); + }); + + it('should display all-day event in import preview', () => { + const dateStr = futureDateStr(15); + selectIcs(buildIcs([ + makeVevent(`VALUE=DATE:${dateStr}`, `VALUE=DATE:${futureDateStr(16)}`, + 'All Day Event', 'tztest-allday-001'), + ])); + cy.get('app-calendar-event-card .upcomingEventCard') + .should('contain', 'All Day Event'); + }); + + it('should display recurring event in import preview', () => { + const dateStr = futureDateStr(15); + selectIcs(buildIcs([ + makeVevent(`${dateStr}T100000`, `${dateStr}T110000`, 'Weekly Standup', 'tztest-recurring-001', + ['RRULE:FREQ=WEEKLY;COUNT=4']), + ])); + cy.get('app-calendar-event-card .upcomingEventCard') + .should('contain', 'Weekly Standup'); + }); + + it('should display citadel-path TZID event in import preview', () => { + const dateStr = futureDateStr(15); + selectIcs(buildIcs([ + makeVevent( + `TZID=/citadel.org/20210210_1/Europe/Oslo:${dateStr}T140000`, + `TZID=/citadel.org/20210210_1/Europe/Oslo:${dateStr}T150000`, + 'Citadel Path Event', 'tztest-citadel-001'), + ], osloVtimezone)); + cy.get('app-calendar-event-card .upcomingEventCard') + .should('contain', 'Citadel Path Event'); + }); + + it('should display multiple events from multi-event ICS', () => { + const dateStr = futureDateStr(15); + selectIcs(buildIcs([ + makeVevent(`${dateStr}T100000`, `${dateStr}T110000`, 'Floating Event', 'tztest-multi-floating'), + makeVevent(`${dateStr}T120000Z`, `${dateStr}T130000Z`, 'UTC Event', 'tztest-multi-utc'), + makeVevent(`TZID=Europe/Oslo:${dateStr}T140000`, `TZID=Europe/Oslo:${dateStr}T150000`, + 'TZID Event', 'tztest-multi-tzid'), + ])); + cy.get('app-calendar-event-card').should('have.length', 3); + cy.get('app-calendar-event-card .upcomingEventCard') + .should('contain', 'Floating Event') + .and('contain', 'UTC Event') + .and('contain', 'TZID Event'); + }); + + it('should fully import a floating time event', () => { + const dateStr = futureDateStr(15); + selectIcs(buildIcs([ + makeVevent(`${dateStr}T140000`, `${dateStr}T150000`, 'Imported Floating Event', 'tztest-import-floating'), + ])); + cy.get('app-calendar-event-card .upcomingEventCard') + .should('contain', 'Imported Floating Event'); + doImport(); + }); + + it('should fully import a UTC event', () => { + const dateStr = futureDateStr(15); + selectIcs(buildIcs([ + makeVevent(`${dateStr}T130000Z`, `${dateStr}T140000Z`, 'Imported UTC Event', 'tztest-import-utc'), + ])); + cy.get('app-calendar-event-card .upcomingEventCard') + .should('contain', 'Imported UTC Event'); + doImport(); + }); +}); diff --git a/e2e/mockserver/mockserver.ts b/e2e/mockserver/mockserver.ts index 36ffae31f..3b0aea1de 100644 --- a/e2e/mockserver/mockserver.ts +++ b/e2e/mockserver/mockserver.ts @@ -223,6 +223,9 @@ END:VCALENDAR if (command === 'disable2fa') { this.challenge2fa = false; } + if (command === 'resetCalendarEvents') { + this.events = []; + } response.end(); return; } @@ -295,6 +298,33 @@ END:VCALENDAR )); return; } + // ICS calendar import: PUT /rest/v1/calendar/ics/{calendar_id} + const icsImportMatch = requesturl.match(/^\/rest\/v1\/calendar\/ics\/(.+)$/); + if (icsImportMatch && request.method === 'PUT') { + let body = ''; + request.on('readable', () => { + body += request.read() || ''; + }); + request.on('end', () => { + const parsed = JSON.parse(body); + const eventCount = (parsed.ical.match(/BEGIN:VEVENT/g) || []).length; + const calendarId = decodeURIComponent(icsImportMatch[1]); + const uidMatch = parsed.ical.match(/UID:(.*)/); + this.events.push({ + id: calendarId + '/' + (uidMatch ? uidMatch[1].trim() : 'imported-' + this.events.length), + ical: parsed.ical, + calendar: calendarId, + }); + response.end(JSON.stringify({ + 'status': 'success', + 'result': { + 'events_imported': eventCount, + } + })); + }); + return; + } + switch (requesturl) { case '/ajax_mfa_authenticate': setTimeout(() => { From 605271ec3e540d44c29ff9e48d6e9810d8926c19 Mon Sep 17 00:00:00 2001 From: Finn Date: Thu, 2 Apr 2026 02:46:49 +0100 Subject: [PATCH 07/42] fix(calendar): use correct ICS property parameter separator in E2E tests ICS properties use semicolons before parameters (DTSTART;TZID=Europe/Oslo:...) not colons. The previous DTSTART:TZID=... format caused the parser to treat the entire TZID string as the property value, breaking TZID, VALUE=DATE, and citadel-path tests. --- e2e/cypress/integration/calendar-timezone.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/e2e/cypress/integration/calendar-timezone.ts b/e2e/cypress/integration/calendar-timezone.ts index f896ffc8b..cad2db0a1 100644 --- a/e2e/cypress/integration/calendar-timezone.ts +++ b/e2e/cypress/integration/calendar-timezone.ts @@ -35,11 +35,16 @@ function buildIcs(veventBlocks: string[], vtimezone?: string): string { return parts.join('\r\n'); } +function dtLine(prefix: string, value: string): string { + // ICS parameters use ';' separator (e.g. DTSTART;TZID=Europe/Oslo:20260416T140000) + return value.includes('=') ? `${prefix};${value}` : `${prefix}:${value}`; +} + function makeVevent(dtstart: string, dtend: string, summary: string, uid: string, extra: string[] = []): string { return [ 'BEGIN:VEVENT', - `DTSTART:${dtstart}`, - `DTEND:${dtend}`, + dtLine('DTSTART', dtstart), + dtLine('DTEND', dtend), `SUMMARY:${summary}`, `UID:${uid}`, 'DTSTAMP:20260101T000000Z', From d79234ad5b801df447f79d8a03d87a7d51f9b04a Mon Sep 17 00:00:00 2001 From: Finn Date: Thu, 2 Apr 2026 09:14:19 +0100 Subject: [PATCH 08/42] fix(calendar): strip milliseconds from test ISO strings to fix CI timezone flakiness moment.toISOString() produces strings with milliseconds (e.g. "2026-04-02T14:30:00.000Z"), which causes ICAL.js to miss the 'Z' UTC designator at index 19 (it finds '.' instead). This makes ICAL.js treat the time as floating/local rather than UTC, leading to different code paths in convertIcalTimeToDate on UTC CI systems versus local machines. Introduce toIcalISOString() helper that formats without milliseconds and apply it to simpleEvents and recurringEvents test data. --- .../calendar-app.component.spec.ts | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/app/calendar-app/calendar-app.component.spec.ts b/src/app/calendar-app/calendar-app.component.spec.ts index 1a22bdd78..1d145cadd 100644 --- a/src/app/calendar-app/calendar-app.component.spec.ts +++ b/src/app/calendar-app/calendar-app.component.spec.ts @@ -45,20 +45,28 @@ describe('CalendarAppComponent', () => { let component: CalendarAppComponent; let fixture: ComponentFixture; + // moment.toISOString() includes milliseconds (e.g. "2026-04-02T14:30:00.000Z"), + // which causes ICAL.js's fromDateTimeString to miss the 'Z' UTC designator + // (it checks index 19, but milliseconds push 'Z' to index 23). + // This helper produces ICAL-compatible ISO strings without milliseconds. + function toIcalISOString(m: moment.Moment): string { + return m.format('YYYY-MM-DDTHH:mm:ss') + 'Z'; + } + // jCal spec: https://tools.ietf.org/html/rfc7265 const simpleEvents = [ {'id': 'test-calendar/event0', 'ical': new ICAL.Component(['vcalendar', [], [ [ 'vevent', [ - [ 'dtstart', {}, 'date-time', moment().toISOString() ], - [ 'dtend', {}, 'date-time', moment().add(1, 'hour').toISOString() ], + [ 'dtstart', {}, 'date-time', toIcalISOString(moment()) ], + [ 'dtend', {}, 'date-time', toIcalISOString(moment().add(1, 'hour')) ], [ 'summary', {}, 'text', 'Test Event #0' ], ]]]]).toString(), 'calendar': 'test-calendar', }, {'id': 'test-calendar/event1', 'ical': new ICAL.Component(['vcalendar', [], [ [ 'vevent', [ - [ 'dtstart', {}, 'date-time', moment().date(15).add(1, 'month').add(1, 'day').add(2, 'hour').toISOString() ], - [ 'dtend', {}, 'date-time', moment().date(15).add(1, 'month').add(1, 'day').add(3, 'hour').toISOString() ], + [ 'dtstart', {}, 'date-time', toIcalISOString(moment().date(15).add(1, 'month').add(1, 'day').add(2, 'hour')) ], + [ 'dtend', {}, 'date-time', toIcalISOString(moment().date(15).add(1, 'month').add(1, 'day').add(3, 'hour')) ], [ 'summary', {}, 'text', 'Event #1, next month' ], ]]]]).toString(), 'calendar': 'test-calendar', @@ -68,8 +76,8 @@ describe('CalendarAppComponent', () => { const recurringEvents = [ { 'id': 'test-calendar/recurring', 'ical': new ICAL.Component(['vcalendar', [], [ [ 'vevent', [ - [ 'dtstart', {}, 'date-time', moment().date(15).toISOString() ], - [ 'dtend', {}, 'date-time', moment().add(1, 'hour').date(15).toISOString() ], + [ 'dtstart', {}, 'date-time', toIcalISOString(moment().date(15)) ], + [ 'dtend', {}, 'date-time', toIcalISOString(moment().add(1, 'hour').date(15)) ], [ 'summary', {}, 'text', 'Weekly Event #0' ], [ 'rrule', {}, 'recur', {'freq': 'WEEKLY'} ], ]]]]).toString(), From 73d4d88e0302f6e7752177bab5c1f393c02d4921 Mon Sep 17 00:00:00 2001 From: Finn Date: Thu, 16 Apr 2026 11:40:03 +0100 Subject: [PATCH 09/42] fix(calendar): fix timezone handling when account tz differs from browser tz Fix event times displaying incorrectly when account timezone differs from browser timezone. The root cause was momentToIcalTime assigning account tz to browser-local hours then converting, causing double-conversion when browser tz != account tz. Now constructs ICAL.Time from UTC fields and converts to target zone, avoiding the double-conversion. - Rewrite momentToIcalTime to go through UTC instead of browser-local - Fix month view template to use Angular date pipe instead of getHours() - Add null safety for timezone lookups and ICAL.Recur access - Extract getRecur() and getAccountTimezone() helpers to reduce duplication - Add E2E tests for London TZID, floating time, and timezone change bugs - Add unit tests for round-trip time, exception storage, and tz change - Extract ICS test helpers into shared support module - Add mockserver timezone fixture endpoints for E2E testing --- e2e/cypress/integration/calendar-timezone.ts | 57 +--- e2e/cypress/integration/calendar-tz-bugs.ts | 295 ++++++++++++++++++ e2e/cypress/plugins/index.js | 12 + e2e/cypress/support/ics-helpers.ts | 95 ++++++ e2e/mockserver/mockserver.ts | 84 ++++- .../calendar-app/calendar-app.component.html | 4 +- src/app/calendar-app/calendar.service.ts | 16 +- .../event-editor-dialog.component.ts | 2 +- .../runbox-calendar-event.spec.ts | 188 ++++++++++- src/app/calendar-app/runbox-calendar-event.ts | 221 +++++++------ 10 files changed, 792 insertions(+), 182 deletions(-) create mode 100644 e2e/cypress/integration/calendar-tz-bugs.ts create mode 100644 e2e/cypress/support/ics-helpers.ts diff --git a/e2e/cypress/integration/calendar-timezone.ts b/e2e/cypress/integration/calendar-timezone.ts index cad2db0a1..7cf69268f 100644 --- a/e2e/cypress/integration/calendar-timezone.ts +++ b/e2e/cypress/integration/calendar-timezone.ts @@ -18,62 +18,7 @@ // along with Runbox 7. If not, see . // ---------- END RUNBOX LICENSE ---------- -function futureDateStr(daysFromNow: number): string { - const d = new Date(); - d.setDate(d.getDate() + daysFromNow); - return d.toISOString().replace(/-/g, '').replace(/T.*/, ''); -} - -function buildIcs(veventBlocks: string[], vtimezone?: string): string { - const parts = [ - 'BEGIN:VCALENDAR', - 'VERSION:2.0', - 'PRODID:-//Runbox//E2E Test//EN', - ]; - if (vtimezone) { parts.push(vtimezone); } - parts.push(...veventBlocks, 'END:VCALENDAR'); - return parts.join('\r\n'); -} - -function dtLine(prefix: string, value: string): string { - // ICS parameters use ';' separator (e.g. DTSTART;TZID=Europe/Oslo:20260416T140000) - return value.includes('=') ? `${prefix};${value}` : `${prefix}:${value}`; -} - -function makeVevent(dtstart: string, dtend: string, summary: string, uid: string, extra: string[] = []): string { - return [ - 'BEGIN:VEVENT', - dtLine('DTSTART', dtstart), - dtLine('DTEND', dtend), - `SUMMARY:${summary}`, - `UID:${uid}`, - 'DTSTAMP:20260101T000000Z', - ...extra, - 'END:VEVENT', - ].join('\r\n'); -} - -const osloVtimezone = [ - 'BEGIN:VTIMEZONE', - 'TZID:/citadel.org/20210210_1/Europe/Oslo', - 'LAST-MODIFIED:20210210T123706Z', - 'X-LIC-LOCATION:Europe/Oslo', - 'BEGIN:STANDARD', - 'TZNAME:CET', - 'TZOFFSETFROM:+0200', - 'TZOFFSETTO:+0100', - 'DTSTART:19961027T030000', - 'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU', - 'END:STANDARD', - 'BEGIN:DAYLIGHT', - 'TZNAME:CEST', - 'TZOFFSETFROM:+0100', - 'TZOFFSETTO:+0200', - 'DTSTART:19810329T020000', - 'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU', - 'END:DAYLIGHT', - 'END:VTIMEZONE', -].join('\r\n'); +import { futureDateStr, buildIcs, makeVevent, osloVtimezone } from '../support/ics-helpers'; describe('Calendar timezone handling', () => { beforeEach(() => { diff --git a/e2e/cypress/integration/calendar-tz-bugs.ts b/e2e/cypress/integration/calendar-tz-bugs.ts new file mode 100644 index 000000000..d31379d09 --- /dev/null +++ b/e2e/cypress/integration/calendar-tz-bugs.ts @@ -0,0 +1,295 @@ +/// +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2016-2026 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + +// Reproduction tests for staging feedback on PR #1779 + +import { futureDateStr, buildIcs, makeVevent, osloVtimezone, londonVtimezone } from '../support/ics-helpers'; + +/** + * Bug 1 (staging feedback): Creating an event with account tz = UK (London), + * local browser tz = CET (Oslo), event time 12:00 shows as 13:00 when editing. + * + * The root cause: EventEditorDialogComponent uses bare Date objects for + * event_start/event_end. The month view template uses event.start.getHours() + * which returns browser-local time, not account timezone time. + * + * We simulate a non-London browser by overriding Date.prototype.getHours + * to shift by +1h (simulating CEST = UTC+2 when account is BST = UTC+1). + * + * The event is at 12:00 London (BST = UTC+1 in summer) = 11:00 UTC. + * In account timezone London, it should display as 12:00. + * But event.start.getHours() returns browser-local time, which for a CET + * browser is 13:00 (11:00 UTC + 2h CEST). + */ + +// Simulates a browser in a different timezone by shifting getHours(). +// offsetHours: how many hours to add to the real getHours() result. +function mockBrowserTimezone(offsetHours: number) { + cy.visit('/calendar', { + onBeforeLoad(win) { + const origGetHours = win.Date.prototype.getHours; + win.Date.prototype.getHours = function () { + return (origGetHours.call(this) + offsetHours) % 24; + }; + }, + }); +} + +describe('Calendar timezone bug: event time offset when account tz differs', () => { + beforeEach(() => { + cy.request('/rest/e2e/resetCalendarEvents'); + // Set account timezone to London (differs from simulated CET/Oslo browser) + cy.request('/rest/e2e/setTimezone_Europe/London'); + }); + + afterEach(() => { + cy.request('/rest/e2e/resetTimezone'); + }); + + it('should display London TZID event at correct hour for London account', () => { + const dateStr = futureDateStr(3); + + // Event at 12:00 London (BST = UTC+1 in summer) = 11:00 UTC. + // With account tz = London, the template should display 12:00 via the + // Angular date pipe (which formats the Date in browser-local time). + const ics = buildIcs([ + makeVevent( + `TZID=Europe/London:${dateStr}T120000`, + `TZID=Europe/London:${dateStr}T130000`, + 'London Noon Meeting', 'bug1-london-001'), + ], londonVtimezone); + + // Pre-load the event into the mock server + cy.request({ + method: 'POST', + url: '/rest/e2e/addEvent', + body: { + id: 'mock cal/bug1-london-001', + ical: ics, + calendar: 'mock cal', + }, + }); + + cy.visit('/calendar'); + cy.get('.calendarListItem').should('have.length', 1); + + cy.get('button.calendarMonthDayEvent') + .should('contain', 'London Noon Meeting'); + + // The date pipe formats the Date (11:00 UTC) in browser-local time. + // On a BST machine: 11:00 + 1h = 12:00 + cy.get('button.calendarMonthDayEvent') + .contains('London Noon Meeting') + .invoke('text') + .should('match', /12:00/); + }); + + it('should display floating time event at correct hour matching account tz', () => { + const dateStr = futureDateStr(3); + + // Floating time event at 14:00 (no TZID). + // With account tz = London, floating time is interpreted as London time. + // 14:00 London (BST = UTC+1) = 13:00 UTC + const ics = buildIcs([ + makeVevent( + `${dateStr}T140000`, + `${dateStr}T150000`, + 'Floating 2pm Meeting', 'bug1-floating-001'), + ]); + + cy.request({ + method: 'POST', + url: '/rest/e2e/addEvent', + body: { + id: 'mock cal/bug1-floating-001', + ical: ics, + calendar: 'mock cal', + }, + }); + + cy.visit('/calendar'); + cy.get('.calendarListItem').should('have.length', 1); + + cy.get('button.calendarMonthDayEvent') + .should('contain', 'Floating 2pm Meeting'); + + // Floating 14:00 interpreted as London BST = 13:00 UTC. + // Date pipe on BST machine: 13:00 + 1h = 14:00 + cy.get('button.calendarMonthDayEvent') + .contains('Floating 2pm Meeting') + .invoke('text') + .should('match', /14:00/); + }); +}); + +/** + * Bug 3 (staging feedback): Changing account timezone doesn't update displayed + * event times, even after reloading the app. + * + * Root cause: CalendarService stores RunboxCalendarEvent instances with timezone + * set at construction time. When the user changes their account timezone in + * Personal Details, the existing event instances keep the old timezone. + * CalendarService doesn't re-generate events when the timezone changes. + */ +describe('Calendar timezone bug: events do not update after timezone change', () => { + beforeEach(() => { + cy.request('/rest/e2e/resetCalendarEvents'); + cy.request('/rest/e2e/resetTimezone'); + }); + + afterEach(() => { + cy.request('/rest/e2e/resetTimezone'); + }); + + it('should update displayed times when account timezone changes', () => { + const dateStr = futureDateStr(3); + + // Event at 14:00 Oslo time (CEST = UTC+2 in summer) = 12:00 UTC + const ics = buildIcs([ + makeVevent( + `TZID=/citadel.org/20210210_1/Europe/Oslo:${dateStr}T140000`, + `TZID=/citadel.org/20210210_1/Europe/Oslo:${dateStr}T150000`, + 'Oslo 2pm Meeting', 'bug3-oslo-001'), + ], osloVtimezone); + + cy.request({ + method: 'POST', + url: '/rest/e2e/addEvent', + body: { + id: 'mock cal/bug3-oslo-001', + ical: ics, + calendar: 'mock cal', + }, + }); + + // Load calendar with Oslo timezone, simulating CEST browser + cy.visit('/calendar', { + onBeforeLoad(win) { + // Simulate CEST browser to match Oslo account tz + const origGetHours = win.Date.prototype.getHours; + win.Date.prototype.getHours = function () { + return (origGetHours.call(this) + 1) % 24; + }; + }, + }); + cy.get('.calendarListItem').should('have.length', 1); + cy.get('button.calendarMonthDayEvent') + .should('contain', 'Oslo 2pm Meeting'); + + // Capture the displayed time with Oslo account tz + cy.get('button.calendarMonthDayEvent') + .contains('Oslo 2pm Meeting') + .invoke('text') + .then(osloText => { + // Now change account timezone to London + cy.request('/rest/e2e/setTimezone_Europe/London'); + + // Reload with same CEST browser mock — account tz now differs + cy.visit('/calendar', { + onBeforeLoad(win) { + const origGetHours = win.Date.prototype.getHours; + win.Date.prototype.getHours = function () { + return (origGetHours.call(this) + 1) % 24; + }; + }, + }); + cy.get('.calendarListItem').should('have.length', 1); + cy.get('button.calendarMonthDayEvent') + .should('contain', 'Oslo 2pm Meeting'); + + // With London timezone, the event should display differently: + // 14:00 CEST = 12:00 UTC = 13:00 BST (London) + // The displayed hour SHOULD change from Oslo display to London display. + cy.get('button.calendarMonthDayEvent') + .contains('Oslo 2pm Meeting') + .invoke('text') + .should(not => { + // The displayed time should have changed from the Oslo display. + }); + }); + }); + + it('should show different displayed hour after timezone change', () => { + const dateStr = futureDateStr(3); + + // Floating time 12:00. With Oslo account, interpreted as 12:00 CEST = 10:00 UTC + // With London account, interpreted as 12:00 BST = 11:00 UTC + const ics = buildIcs([ + makeVevent( + `${dateStr}T120000`, + `${dateStr}T130000`, + 'Floating Noon', 'bug3-floating-001'), + ]); + + // Load with Oslo timezone first, simulating CEST browser + cy.request({ + method: 'POST', + url: '/rest/e2e/addEvent', + body: { + id: 'mock cal/bug3-floating-001', + ical: ics, + calendar: 'mock cal', + }, + }); + + cy.visit('/calendar', { + onBeforeLoad(win) { + const origGetHours = win.Date.prototype.getHours; + win.Date.prototype.getHours = function () { + return (origGetHours.call(this) + 1) % 24; + }; + }, + }); + cy.get('.calendarListItem').should('have.length', 1); + + // Get displayed hour with Oslo tz + CEST browser mock + cy.get('button.calendarMonthDayEvent') + .contains('Floating Noon') + .invoke('text') + .then(osloText => { + const osloHour = osloText.match(/(\d+):\d+/); + + // Switch to London, keep same CEST browser mock + cy.request('/rest/e2e/setTimezone_Europe/London'); + cy.visit('/calendar', { + onBeforeLoad(win) { + const origGetHours = win.Date.prototype.getHours; + win.Date.prototype.getHours = function () { + return (origGetHours.call(this) + 1) % 24; + }; + }, + }); + cy.get('.calendarListItem').should('have.length', 1); + + cy.get('button.calendarMonthDayEvent') + .contains('Floating Noon') + .invoke('text') + .then(londonText => { + const londonHour = londonText.match(/(\d+):\d+/); + // After timezone change, the event should be re-interpreted. + // Bug: Currently the hours will be THE SAME because + // CalendarService doesn't regenerate events on tz change. + // After fix: they should differ. + expect(londonHour?.[1]).to.not.equal(osloHour?.[1], + 'Displayed hour should change after timezone change'); + }); + }); + }); +}); diff --git a/e2e/cypress/plugins/index.js b/e2e/cypress/plugins/index.js index c1f7270f1..92e2bc043 100644 --- a/e2e/cypress/plugins/index.js +++ b/e2e/cypress/plugins/index.js @@ -19,4 +19,16 @@ module.exports = (on, config) => { } on('file:preprocessor', wp(options)) require('cypress-terminal-report/src/installLogsPrinter')(on); + + // Force the browser into a specific timezone via TZ env var. + // Defaults to Europe/Oslo (CET/CEST) to expose bugs where + // account tz != browser tz. Override with env var CYPRESS_TZ. + on('before:browser:launch', (browser, launchOptions) => { + const tz = config.env.TZ || 'Europe/Oslo'; + launchOptions.env = { + ...(launchOptions.env || {}), + TZ: tz, + }; + return launchOptions; + }); } diff --git a/e2e/cypress/support/ics-helpers.ts b/e2e/cypress/support/ics-helpers.ts new file mode 100644 index 000000000..146134ddb --- /dev/null +++ b/e2e/cypress/support/ics-helpers.ts @@ -0,0 +1,95 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2016-2026 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + +export function futureDateStr(daysFromNow: number): string { + const d = new Date(); + d.setDate(d.getDate() + daysFromNow); + return d.toISOString().replace(/-/g, '').replace(/T.*/, ''); +} + +export function buildIcs(veventBlocks: string[], vtimezone?: string): string { + const parts = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Runbox//E2E Test//EN', + ]; + if (vtimezone) { parts.push(vtimezone); } + parts.push(...veventBlocks, 'END:VCALENDAR'); + return parts.join('\r\n'); +} + +export function dtLine(prefix: string, value: string): string { + return value.includes('=') ? `${prefix};${value}` : `${prefix}:${value}`; +} + +export function makeVevent(dtstart: string, dtend: string, summary: string, uid: string, extra: string[] = []): string { + return [ + 'BEGIN:VEVENT', + dtLine('DTSTART', dtstart), + dtLine('DTEND', dtend), + `SUMMARY:${summary}`, + `UID:${uid}`, + 'DTSTAMP:20260101T000000Z', + ...extra, + 'END:VEVENT', + ].join('\r\n'); +} + +export const osloVtimezone = [ + 'BEGIN:VTIMEZONE', + 'TZID:/citadel.org/20210210_1/Europe/Oslo', + 'LAST-MODIFIED:20210210T123706Z', + 'X-LIC-LOCATION:Europe/Oslo', + 'BEGIN:STANDARD', + 'TZNAME:CET', + 'TZOFFSETFROM:+0200', + 'TZOFFSETTO:+0100', + 'DTSTART:19961027T030000', + 'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU', + 'END:STANDARD', + 'BEGIN:DAYLIGHT', + 'TZNAME:CEST', + 'TZOFFSETFROM:+0100', + 'TZOFFSETTO:+0200', + 'DTSTART:19810329T020000', + 'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU', + 'END:DAYLIGHT', + 'END:VTIMEZONE', +].join('\r\n'); + +export const londonVtimezone = [ + 'BEGIN:VTIMEZONE', + 'TZID:Europe/London', + 'X-LIC-LOCATION:Europe/London', + 'BEGIN:STANDARD', + 'TZNAME:GMT', + 'TZOFFSETFROM:+0100', + 'TZOFFSETTO:+0000', + 'DTSTART:19701025T020000', + 'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU', + 'END:STANDARD', + 'BEGIN:DAYLIGHT', + 'TZNAME:BST', + 'TZOFFSETFROM:+0000', + 'TZOFFSETTO:+0100', + 'DTSTART:19810329T010000', + 'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU', + 'END:DAYLIGHT', + 'END:VTIMEZONE', +].join('\r\n'); diff --git a/e2e/mockserver/mockserver.ts b/e2e/mockserver/mockserver.ts index 3b0aea1de..9227c6907 100644 --- a/e2e/mockserver/mockserver.ts +++ b/e2e/mockserver/mockserver.ts @@ -22,23 +22,24 @@ import { createWriteStream } from 'fs'; import { mail_message_obj } from './emailresponse'; const logger = createWriteStream('mockserver.log'); -function log(line) { +function log(line: string) { logger.write(line + '\n'); } export class MockServer { - server: Server; + server!: Server; loggedIn = true; challenge2fa = false; port = 15000; - calendars = [ - { id: 'mock cal', displayname: 'Mock Calendar' }, + calendars: { id: string; displayname: string; syncToken: string }[] = [ + { id: 'mock cal', displayname: 'Mock Calendar', syncToken: 'e2e-sync-1' }, ]; - events = []; + events: { id: string; ical: any; calendar: string }[] = []; + accountTimezone = 'Europe/Oslo'; folders = [ { @@ -203,12 +204,37 @@ RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU END:STANDARD END:VTIMEZONE END:VCALENDAR +`; + + vtimezone_london = +`BEGIN:VCALENDAR +PRODID:-//runbox//NONSGML Runbox calendar//EN +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:Europe/London +X-LIC-LOCATION:Europe/London +BEGIN:STANDARD +TZNAME:GMT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0000 +DTSTART:19701025T020000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +BEGIN:DAYLIGHT +TZNAME:BST +TZOFFSETFROM:+0000 +TZOFFSETTO:+0100 +DTSTART:19810329T010000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +END:VTIMEZONE +END:VCALENDAR `; public start() { log('Starting mock server'); this.server = createServer((request, response) => { - const e2eFixture = request.url.match(/\/rest\/e2e\/(\w+)/); + const e2eFixture = request.url?.match(/\/rest\/e2e\/([^?]+)/); if (e2eFixture) { log(e2eFixture[1]); const command = e2eFixture[1]; @@ -225,6 +251,31 @@ END:VCALENDAR } if (command === 'resetCalendarEvents') { this.events = []; + this.bumpSyncToken(); + } + if (command.startsWith('setTimezone_')) { + this.accountTimezone = command.replace('setTimezone_', ''); + } + if (command === 'resetTimezone') { + this.accountTimezone = 'Europe/Oslo'; + } + if (command === 'addEvent') { + // POST with JSON body: { id, ical, calendar } + let body = ''; + request.on('readable', () => { + body += request.read() || ''; + }); + request.on('end', () => { + const parsed = JSON.parse(body); + this.events.push({ + id: parsed.id || ('e2e-event-' + this.events.length), + ical: parsed.ical, + calendar: parsed.calendar || 'mock cal', + }); + this.bumpSyncToken(); + response.end(JSON.stringify({ status: 'success' })); + }); + return; } response.end(); return; @@ -235,7 +286,7 @@ END:VCALENDAR return; } log(request.method + ' ' + request.url); - let requesturl = request.url; + let requesturl = request.url || ''; if (requesturl.indexOf('/rest/v1/list/deleted_messages') === 0) { requesturl = '/rest/v1/list/deleted_messages'; } @@ -254,7 +305,7 @@ END:VCALENDAR if (bulkemailendpiont) { const ids = bulkemailendpiont[1].split(',').map(id => parseInt(id, 10)); - const messages = {}; + const messages: { [key: number]: { json: any } } = {}; for (const id of ids) { messages[id] = { json: this.getMessage(id).result }; } @@ -479,7 +530,7 @@ END:VCALENDAR 'last_name': 'User', 'email_alternative': 'test@example.com', 'country': 'NO', - 'timezone': 'Europe/Oslo', + 'timezone': this.accountTimezone, 'phone_number': '', 'company': '', 'email_alternative_status': 0 @@ -507,8 +558,11 @@ END:VCALENDAR case '/_ics/Europe/Oslo.ics': response.end(this.vtimezone_oslo); break; + case '/_ics/Europe/London.ics': + response.end(this.vtimezone_london); + break; default: - if (request.url.indexOf('/rest') === 0) { + if (request.url && request.url.indexOf('/rest') === 0) { response.end(JSON.stringify({ status: 'success' })); } else { response.end(''); @@ -578,7 +632,7 @@ END:VCALENDAR }, 'company': null, 'is_overwrite_subaccount_ip_rules': 0, 'currency': 'EUR', - 'user_created': null, 'timezone': 'Europe/Oslo', 'uid': 221, + 'user_created': null, 'timezone': this.accountTimezone, 'uid': 221, 'sub_accounts': ['test%subaccount.com'], 'password_strength': 5, 'gender': null, 'has_sub_accounts': 1, 'need2pay': 'n', 'paid': 'n', 'country': null @@ -938,7 +992,7 @@ END:VCALENDAR }; } - createFolder(request, response) { + createFolder(request: any, response: any) { let body = ''; request.on('readable', () => { body += request.read() || ''; @@ -1027,6 +1081,12 @@ END:VCALENDAR }; } + bumpSyncToken() { + const current = this.calendars[0].syncToken || ''; + const num = parseInt(current.split('-').pop() || '0', 10) || 0; + this.calendars[0].syncToken = 'e2e-sync-' + (num + 1); + } + getCalendars() { return { 'status': 'success', diff --git a/src/app/calendar-app/calendar-app.component.html b/src/app/calendar-app/calendar-app.component.html index d539940ae..517b2d517 100644 --- a/src/app/calendar-app/calendar-app.component.html +++ b/src/app/calendar-app/calendar-app.component.html @@ -12,8 +12,8 @@ *ngFor="let event of (day.events.length > 4 ? day.events.slice(0, 3) : day.events)" (click)="openEvent(event); $event.stopPropagation()" > - - {{ event.start.getHours() }}:{{ ('0' + event.start.getMinutes()).slice(-2) }} + + {{ event.start | date:'HH:mm' }} {{ event.title }} diff --git a/src/app/calendar-app/calendar.service.ts b/src/app/calendar-app/calendar.service.ts index 9929a3b09..1445cb475 100644 --- a/src/app/calendar-app/calendar.service.ts +++ b/src/app/calendar-app/calendar.service.ts @@ -332,7 +332,11 @@ export class CalendarService implements OnDestroy { // from its rrule at some point - check to see if we already // have the same uid if (vevents.length === 0) { - const ievent = new ICAL.Event(component.getFirstSubcomponent('vevent')); + const vevent = component.getFirstSubcomponent('vevent'); + if (!vevent) { + return { id, event: null }; + } + const ievent = new ICAL.Event(vevent); const existingEvent = this.icalevents.find( (entry) => entry['id'] === ievent.uid ); @@ -342,7 +346,7 @@ export class CalendarService implements OnDestroy { // we could save modified event, and delete this one // or keep doing this and leave that for a different fix? } - return; + return { id, event: null }; } if (keep) { this.icalevents.push({ 'id': id, 'event': vevents[0] }); @@ -444,7 +448,7 @@ export class CalendarService implements OnDestroy { // we need to copy it to a new calendar, and remove it from the old one. this.addEvent(event).then(id => { console.log('Event recreated as', id); - this.deleteEvent(event._old_id); + this.deleteEvent(event._old_id || ''); }); } else { // simple case: just modify the event in place @@ -477,7 +481,7 @@ export class CalendarService implements OnDestroy { // console.log('Found timezone data:', tzData); // VCALENDAR with VTIMEZONE in it const component = new ICAL.Component(ICAL.parse(tzData)); - let tz; + let tz: ICAL.Timezone | undefined; if (component.getFirstSubcomponent('vtimezone')) { for (const tzComponent of component.getAllSubcomponents('vtimezone')) { // TZIDs in vzic are, eg: /citadel.org/20210210_1/Europe/London @@ -492,7 +496,9 @@ export class CalendarService implements OnDestroy { } } } - this.userTimezoneLoaded.next(tz); + if (tz) { + this.userTimezoneLoaded.next(tz); + } this.userTimezoneLoaded.complete(); }); } diff --git a/src/app/calendar-app/event-editor-dialog.component.ts b/src/app/calendar-app/event-editor-dialog.component.ts index dd5899481..6457ad953 100644 --- a/src/app/calendar-app/event-editor-dialog.component.ts +++ b/src/app/calendar-app/event-editor-dialog.component.ts @@ -145,7 +145,7 @@ export class EventEditorDialogComponent { const hasDay = setDays.find((entry) => entry['day'] === day.val); if (hasDay) { day['recurs_on'] = true; - if (hasDay['numth'] > 0) { + if (parseInt(hasDay['numth'], 10) > 0) { this.recur_by_monthyeardays.push(hasDay['numth']); } recurs_on_weekdays.push(day.val); diff --git a/src/app/calendar-app/runbox-calendar-event.spec.ts b/src/app/calendar-app/runbox-calendar-event.spec.ts index 41b6d2f1d..52b47de49 100644 --- a/src/app/calendar-app/runbox-calendar-event.spec.ts +++ b/src/app/calendar-app/runbox-calendar-event.spec.ts @@ -1,3 +1,4 @@ +/// // --------- BEGIN RUNBOX LICENSE --------- // Copyright (C) 2016-2018 Runbox Solutions AS (runbox.com). // @@ -196,11 +197,11 @@ describe('RunboxCalendarEvent', () => { false, sut.calendar, RecurSaveType.THIS_ONLY, - 'Moved weekly event', undefined, undefined, + 'Moved weekly event', '', '', true, sut.recurringFrequency, sut.recurInterval, - undefined, undefined, undefined, // and optional params.. + [], [], [] // and optional params.. ); expect(sut.toIcal()).toContain('SUMMARY:Moved weekly event'); @@ -352,17 +353,20 @@ END:VCALENDAR` console.log('event start :' + sut.start.toISOString()); // 07:00 UTC (which is 03:00 New York EDT) expect(sut.start.toISOString()).toBe('2021-05-15T07:00:00.000Z'); - // Move this one an hour later - // TZ? - const future = moment('2021-05-15T04:00:00'); - const future_end = moment('2021-05-15T05:00:00'); + // Move this one an hour later (09:00 -> 10:00 Berlin time) + // Use moment.tz to create a deterministic time in the event's timezone, + // simulating what the dialog would pass after user edits the time. + // momentToIcalTime now converts via UTC, so this produces the correct + // 10:00 Berlin regardless of the test machine's local timezone. + const future = moment.tz('2021-05-15T10:00:00', 'Europe/Berlin'); + const future_end = moment.tz('2021-05-15T11:00:00', 'Europe/Berlin'); sut.updateEvent( future, future_end, false, sut.calendar, RecurSaveType.THIS_ONLY, - 'Moved daily event one hour', undefined, undefined, + 'Moved daily event one hour', '', '', true, sut.recurringFrequency, sut.recurInterval, @@ -484,9 +488,10 @@ TZOFFSETTO:+${dst}00 RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU END:DAYLIGHT END:VTIMEZONE`; - const comp = new ICAL.Component(ICAL.parse(tzData)); - ICAL.TimezoneService.register(tzid, new ICAL.Timezone({ tzid, component: comp })); - } +const comp = new ICAL.Component(ICAL.parse(tzData)); +const tz = new ICAL.Timezone({ tzid, component: comp }); +ICAL.TimezoneService.register(tz.tzid, tz); +} it('should correctly convert London TZID event to Berlin user timezone', () => { // Simulates an issue: 4pm London event shown as 3pm (wrong) instead of 5pm (correct) @@ -609,4 +614,167 @@ END:VTIMEZONE`; ); expect(eventSimple.start.getUTCHours()).toBe(16, 'Simple tz name should also work'); }); + + // Reproduction tests for staging feedback (PR #1779) + + it('should round-trip entered time correctly when creating event via updateEvent', () => { + // Bug 1 (staging feedback): User in CET browser, account tz = UK (London) + // User enters 12:00, event shows as 13:00 when reopening edit dialog. + // + // The model round-trip is actually correct: the ICAL data stores 12:00 London + // and dtstart.hour() returns 12. The bug is in EventEditorDialogComponent which + // uses bare Date objects (displayed in browser local time). + ensureTimezone('Europe/London', 0, 1); + + const event = RunboxCalendarEvent.newEmpty('Europe/London'); + const startMoment = moment('2026-04-14T12:00:00').seconds(0).milliseconds(0); + const endMoment = startMoment.clone().add(1, 'hour'); + + event.updateEvent( + startMoment, endMoment, false, 'test-cal', + RecurSaveType.ALL_OCCURENCES, 'Test Event', '', '', + false, '', 0, [], [], [] + ); + + // dtstart (timezone-aware moment) should show 12:00 in London + expect(event.dtstart.hour()).toBe(12, + 'dtstart should show 12:00 in account timezone (London)'); + // April = BST (UTC+1), so 12:00 London = 11:00 UTC + expect(event.start.toISOString()).toBe('2026-04-14T11:00:00.000Z', + 'start Date should be 11:00 UTC (12:00 BST)'); + }); + + it('should store exception at user-entered time when event tz differs from account tz', () => { + // Bug 2 (staging feedback): Recurring event at 09:00 Berlin, account tz = London. + // User edits one occurrence from 09:00 to 10:00 (shown in browser local time). + // momentToIcalTime tags the time with account tz (London), then converts to + // event tz (Berlin) — this double-conversion shifts the time by the London↔Berlin offset. + ensureTimezone('Europe/London', 0, 1); + ensureTimezone('/freeassociation.sourceforge.net/Europe/Berlin', 1, 2); + + const jcal = ICAL.parse( +`BEGIN:VCALENDAR +CALSCALE:GREGORIAN +PRODID:-//Test//Test//EN +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:/freeassociation.sourceforge.net/Europe/Berlin +X-LIC-LOCATION:Europe/Berlin +BEGIN:DAYLIGHT +TZNAME:CEST +DTSTART:19810328T020000 +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 +END:DAYLIGHT +BEGIN:STANDARD +TZNAME:CET +DTSTART:19961031T030000 +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +UID:bug2-recurring-test +DTSTAMP:20210511T111559Z +DTSTART;TZID=/freeassociation.sourceforge.net/Europe/Berlin: + 20210514T090000 +DTEND;TZID=/freeassociation.sourceforge.net/Europe/Berlin: + 20210514T100000 +SUMMARY:Daily Berlin 9am +RRULE:FREQ=DAILY;INTERVAL=1;COUNT=5 +END:VEVENT +END:VCALENDAR` + ); + const ical = new ICAL.Component(jcal); + for (const tzComponent of ical.getAllSubcomponents('vtimezone')) { + const tz = new ICAL.Timezone({ + tzid: tzComponent.getFirstPropertyValue('tzid'), + component: tzComponent, + }); + if (!ICAL.TimezoneService.has(tz.tzid)) { + ICAL.TimezoneService.register(tz.tzid, tz); + } + } + const vevent = ical.getFirstSubcomponent('vevent'); + const dtstartProp = vevent.getFirstProperty('dtstart'); + + // Day 2 instance (May 15 at 09:00 Berlin) + const sut = new RunboxCalendarEvent( + 'testcal/bug2', + new ICAL.Event(vevent), + ICAL.Time.fromDateTimeString('2021-05-15T09:00:00', dtstartProp), + ICAL.Time.fromDateTimeString('2021-05-15T10:00:00', dtstartProp), + 'Europe/London' // account tz = London (differs from event tz = Berlin) + ); + + // Verify original: 09:00 Berlin (CEST=UTC+2) = 07:00 UTC + expect(sut.start.toISOString()).toBe('2021-05-15T07:00:00.000Z', + 'Original: 09:00 Berlin CEST = 07:00 UTC'); + + // User edits this occurrence from 09:00 to 10:00 in dialog + // Use moment.tz to create a deterministic time in the event's timezone, + // simulating what the dialog would pass after user edits the time. + // momentToIcalTime now converts via UTC, so this produces the correct + // 10:00 Berlin regardless of the test machine's local timezone. + const newStart = moment.tz('2021-05-15T10:00:00', 'Europe/Berlin').seconds(0).milliseconds(0); + const newEnd = moment.tz('2021-05-15T11:00:00', 'Europe/Berlin').seconds(0).milliseconds(0); + sut.updateEvent( + newStart, newEnd, false, sut.calendar, + RecurSaveType.THIS_ONLY, + 'Moved to 10am', undefined, undefined, + true, sut.recurringFrequency, sut.recurInterval, + undefined, undefined, undefined + ); + + // The exception should exist in the ICAL data + expect(sut.toIcal()).toContain('RECURRENCE-ID'); + expect(sut.toIcal()).toContain('Moved to 10am'); + + // The exception DTSTART should be 10:00 Berlin (user entered 10:00) + // NOT 11:00 Berlin (which would result from London→Berlin double-conversion) + // 10:00 Berlin CEST (UTC+2) = 08:00 UTC + expect(sut.toIcal()).toContain('DTSTART;TZID=/freeassociation.sourceforge.net/Europe/Berlin:20210515T100000', + 'Exception should be at 10:00 Berlin (user-entered time)'); + }); + + it('should reflect updated display timezone when timezone property changes', () => { + // Bug 3 (staging feedback): After changing account timezone, event times + // don't update. The timezone property on existing RunboxCalendarEvent instances + // is set once at construction and never refreshed. + ensureTimezone('Europe/London', 0, 1); + ensureTimezone('Europe/Berlin', 1, 2); + + // Event stored as 16:00 London (March = GMT = UTC+0) + const vevent = new ICAL.Component(['vevent', [ + ['dtstart', { tzid: 'Europe/London' }, 'date-time', '2026-03-24T16:00:00'], + ['dtend', { tzid: 'Europe/London' }, 'date-time', '2026-03-24T17:00:00'], + ['summary', {}, 'text', '4pm London'], + ]]); + const dtstartProp = vevent.getFirstProperty('dtstart'); + const event = new RunboxCalendarEvent( + 'test/tzchange', + new ICAL.Event(vevent), + ICAL.Time.fromDateTimeString('2026-03-24T16:00:00', dtstartProp), + ICAL.Time.fromDateTimeString('2026-03-24T17:00:00', dtstartProp), + 'Europe/London' + ); + + // Initially: 4pm London (GMT = UTC+0 in March) = 16:00 UTC + expect(event.start.getUTCHours()).toBe(16); + expect(event.dtstart.hour()).toBe(16, '4pm London shows as 16:00 in London'); + + // User changes account timezone to Berlin + event.timezone = 'Europe/Berlin'; + + // dtstart moment should now show Berlin time + // 16:00 UTC = 17:00 CET (UTC+1 in March) + expect(event.dtstart.hour()).toBe(17, + 'After tz change to Berlin, 4pm London should display as 5pm (17:00)'); + + // start Date remains the same UTC time (Date is always UTC) + expect(event.start.getUTCHours()).toBe(16, + 'start Date UTC time should not change (16:00 UTC)'); + }); }); diff --git a/src/app/calendar-app/runbox-calendar-event.ts b/src/app/calendar-app/runbox-calendar-event.ts index 58f1f8902..8280152c1 100644 --- a/src/app/calendar-app/runbox-calendar-event.ts +++ b/src/app/calendar-app/runbox-calendar-event.ts @@ -49,20 +49,20 @@ export class RunboxCalendarEvent implements CalendarEvent { // start and end are for display pursposes only, // and will be different from dtstart/dtend in // recurring events - _calendar: string; + _calendar!: string; _old_id?: string; ical: ICAL.Component; event: ICAL.Event; // *display* start/end - for reccurrences will not match the ICAL.Event - private _dtstart: ICAL.Time; - private _dtend: ICAL.Time; + private _dtstart!: ICAL.Time; + private _dtend!: ICAL.Time; // store user's selection of allDay setting while updating the event // required cos neither Moment nor Date have a "date only" functionality // ICAL.Time does tho! - private _allDay: boolean; + private _allDay!: boolean; get calendar(): string { return this._calendar; @@ -114,7 +114,7 @@ export class RunboxCalendarEvent implements CalendarEvent { set dtstart(value: moment.Moment) { // check this before we update: if (this._dtstart.toJSDate().toString() === this.recurStart.toString()) { - this._dtstart = this.momentToIcalTime(value, this.event.startDate ? this.event.startDate.zone : null); + this._dtstart = this.momentToIcalTime(value, this.event.startDate ? this.event.startDate.zone : ICAL.Timezone.utcTimezone); this.event.startDate = this._dtstart; } } @@ -132,7 +132,7 @@ export class RunboxCalendarEvent implements CalendarEvent { // one with a time? (as a side effect that is) set dtend(value: moment.Moment) { if (value && this._dtstart.toJSDate().toString() === this.recurStart.toString()) { - this._dtend = this.momentToIcalTime(value, this.event.endDate ? this.event.endDate.zone : undefined); + this._dtend = this.momentToIcalTime(value, this.event.endDate ? this.event.endDate.zone : ICAL.Timezone.utcTimezone); this.event.endDate = this._dtend; } } @@ -177,7 +177,7 @@ export class RunboxCalendarEvent implements CalendarEvent { (zone.component || zone.tzid === 'UTC'); if (hasProperTimezone) { - const targetTz = ICAL.TimezoneService.get(this.timezone); + const targetTz = this.getAccountTimezone(); if (targetTz) { time = time.convertToZone(targetTz); } @@ -191,7 +191,7 @@ export class RunboxCalendarEvent implements CalendarEvent { if (!hasTzidParam) { // True floating time - interpret in calendar's timezone // First try ICAL.TimezoneService (for non-standard paths with VTIMEZONE data) - const calendarTz = ICAL.TimezoneService.get(this.timezone); + const calendarTz = this.getAccountTimezone(); if (calendarTz) { // Create time directly in calendar's timezone, then convert to UTC via toJSDate() @@ -207,7 +207,7 @@ export class RunboxCalendarEvent implements CalendarEvent { } // Try moment-timezone for standard IANA timezones - const momentZone = moment.tz.zone(this.timezone); + const momentZone = this.timezone ? moment.tz.zone(this.timezone) : null; if (momentZone) { const m = moment.tz([ time.year, @@ -217,7 +217,7 @@ export class RunboxCalendarEvent implements CalendarEvent { time.minute, time.second, 0 - ], this.timezone); + ], this.timezone || 'UTC'); return m.toDate(); } @@ -276,15 +276,14 @@ export class RunboxCalendarEvent implements CalendarEvent { // DAILY, WEEKLY, MONTHLY etc get recurringFrequency(): string { - const recur = this.event.component.getFirstPropertyValue('rrule'); - return recur ? recur.freq : ''; + return this.getRecur()?.freq || ''; } // Only set if the new value is different from the old one // Prevents us accidentally overwriting imported RRULE details // -> dont display the "recurring" select box on exception events? set recurringFrequency(frequency: string) { - const recur = this.event.component.getFirstPropertyValue('rrule'); + const recur = this.getRecur(); if (recur) { recur.freq = frequency; this.event.component.updatePropertyWithValue('rrule', recur); @@ -295,44 +294,49 @@ export class RunboxCalendarEvent implements CalendarEvent { // How often does this repeat (every X freqs) get recurInterval(): number { - const recur = this.event.component.getFirstPropertyValue('rrule'); - return recur ? recur.interval : 1; + return this.getRecur()?.interval || 1; } set recurInterval(interval: number) { - const recur = this.event.component.getFirstPropertyValue('rrule'); - recur.interval = interval; - this.event.component.updatePropertyWithValue('rrule', recur); + const recur = this.getRecur(); + if (recur) { + recur.interval = interval; + this.event.component.updatePropertyWithValue('rrule', recur); + } } // An UNTIL date, a COUNT or null = unset/repeats forever - get recurEnds(): Date | number { - const recur = this.event.component.getFirstPropertyValue('rrule'); - if (recur && recur.until) { - return recur.until.toJSDate(); - } - if (recur && recur.count) { - return recur.count; + get recurEnds(): Date | number | null { + const recur = this.getRecur(); + if (recur) { + if (recur.until) { + return recur.until.toJSDate(); + } + if (recur.count) { + return recur.count; + } } return null; } set recurEnds(end: Date | number) { - const recur = this.event.component.getFirstPropertyValue('rrule'); - recur.until = null; - recur.count = null; - if (typeof end === 'number' && end != null) { - recur.count = end; - } - if (end instanceof Date && end != null) { - // Must be a date (cant do typeof === 'Date' !? - const zone = this.event.startDate.zone; - const icaltime = ICAL.Time.fromJSDate(end); - icaltime.zone = zone; - recur.until = icaltime; + const recur = this.getRecur(); + if (recur) { + recur.until = null; + recur.count = null; + if (typeof end === 'number' && end != null) { + recur.count = end; + } + if (end instanceof Date && end != null) { + // Must be a date (cant do typeof === 'Date' !? + const zone = this.event.startDate.zone; + const icaltime = ICAL.Time.fromJSDate(end); + icaltime.zone = zone; + recur.until = icaltime; + } + // else, everything null, repeats forever. + this.event.component.updatePropertyWithValue('rrule', recur); } - // else, everything null, repeats forever. - this.event.component.updatePropertyWithValue('rrule', recur); } // get "part"s, eg byday, bymonthday, bymonth, byyearday, byweekno @@ -340,72 +344,80 @@ export class RunboxCalendarEvent implements CalendarEvent { // One or more days of the week this rule could run on (or none) // SU, MO etc (or can convert to days of week using .icalDayToNumericDay // if a monthly BYDAY, could be "\+?1SU" or "-2TU" etc - get recursByDay(): string[] { - const recur = this.event.component.getFirstPropertyValue('rrule'); + get recursByDay(): { day: string; numth: string }[] { + const recur = this.getRecur(); if (!recur) { return []; } const bydays = recur.getComponent('byday'); const rgx = /^([+-]?\d+)?(\w{2})$/; - const bydays_mapped = bydays.map((day) => { + const bydays_mapped = bydays.map((day: string) => { const match = day.match(rgx); - const numth = match[1] ? match[1] : '0'; - return { 'day': match[2], 'numth': numth }; + const numth = match && match[1] ? match[1] : '0'; + return { 'day': match && match[2] ? match[2] : '', 'numth': numth }; }); return bydays_mapped; } // replace entire list of day(s) set recursByDay(value: string[]) { - const recur = this.event.component.getFirstPropertyValue('rrule'); - recur.setComponent('byday', value); - this.event.component.updatePropertyWithValue('rrule', recur); + const recur = this.getRecur(); + if (recur) { + recur.setComponent('byday', value); + this.event.component.updatePropertyWithValue('rrule', recur); + } } get recursByMonthDay(): string[] { - const recur = this.event.component.getFirstPropertyValue('rrule'); + const recur = this.getRecur(); if (!recur) { return []; } const bymonthdays = recur.getComponent('bymonthday'); - return bymonthdays.map((day) => day.toString()); + return bymonthdays.map((day: string) => day.toString()); } set recursByMonthDay(value: string[]) { - const recur = this.event.component.getFirstPropertyValue('rrule'); - recur.setComponent('bymonthday', value); - this.event.component.updatePropertyWithValue('rrule', recur); + const recur = this.getRecur(); + if (recur) { + recur.setComponent('bymonthday', value); + this.event.component.updatePropertyWithValue('rrule', recur); + } } get recursByYearDay(): string[] { - const recur = this.event.component.getFirstPropertyValue('rrule'); + const recur = this.getRecur(); if (!recur) { return []; } const byyeardays = recur.getComponent('byyearday'); - return byyeardays.map((day) => day.toString()); + return byyeardays.map((day: string) => day.toString()); } set recursByYearDay(value: string[]) { - const recur = this.event.component.getFirstPropertyValue('rrule'); - recur.setComponent('byyearday', value); - this.event.component.updatePropertyWithValue('rrule', recur); + const recur = this.getRecur(); + if (recur) { + recur.setComponent('byyearday', value); + this.event.component.updatePropertyWithValue('rrule', recur); + } } // Jan-Dec numbered 1-12 get recursByMonth(): string[] { - const recur = this.event.component.getFirstPropertyValue('rrule'); + const recur = this.getRecur(); if (!recur) { return []; } const bymonth = recur.getComponent('bymonth'); - return bymonth.map((month) => month.toString()); + return bymonth.map((month: string) => month.toString()); } set recursByMonth(value: string[]) { - const recur = this.event.component.getFirstPropertyValue('rrule'); - recur.setComponent('bymonth', value); - this.event.component.updatePropertyWithValue('rrule', recur); + const recur = this.getRecur(); + if (recur) { + recur.setComponent('bymonth', value); + this.event.component.updatePropertyWithValue('rrule', recur); + } } get isException(): boolean { @@ -463,30 +475,34 @@ export class RunboxCalendarEvent implements CalendarEvent { thisandfuture: boolean, title: string, description: string, - location: string): boolean { + location: string): boolean | undefined { if (this.isException) { // This shouldnt be possible console.log('Refusing to create an exception of an exception'); - return; + return false; } // clone existing one const new_exception = new ICAL.Event(ICAL.Component.fromString(this.event.toString())); new_exception.component.removeProperty('rrule'); const recurrence_id = origdate; - const new_start = this.momentToIcalTime(startdate, this.event.startDate.zone); - let new_end; + const new_start = this.momentToIcalTime(startdate, this.event.startDate.zone || ICAL.Timezone.utcTimezone); + let new_end: ICAL.Time | undefined; if (enddate) { - new_end = this.momentToIcalTime(enddate, this.event.startDate.zone); + new_end = this.momentToIcalTime(enddate, this.event.startDate.zone || ICAL.Timezone.utcTimezone); } if (this._dtstart.isDate) { recurrence_id.isDate = true; new_start.isDate = true; - new_end.isDate = true; + if (new_end) { + new_end.isDate = true; + } } new_exception.recurrenceId = recurrence_id; if (thisandfuture) { const rId = new_exception.component.getFirstProperty('recurrence-id'); - rId.setParameter('range', 'THISANDFUTURE'); + if (rId) { + rId.setParameter('range', 'THISANDFUTURE'); + } } new_exception.startDate = new_start; if (new_end) { @@ -608,28 +624,28 @@ export class RunboxCalendarEvent implements CalendarEvent { get_overview(): EventOverview[] { const events = []; - const seen = {}; + const seen: { [key: string]: boolean } = {}; - for (let e of this.ical.getAllSubcomponents('vevent')) { - e = new ICAL.Event(e); + for (const component of this.ical.getAllSubcomponents('vevent')) { + const event = new ICAL.Event(component); // Skip duplicate uids (if defined), // to eliminate possible special cases in recurring events. - if (e.uid && seen[e.uid]) { + if (event.uid && seen[event.uid]) { continue; } else { - seen[e.uid] = true; + seen[event.uid] = true; } - const rrule = e.component.getFirstPropertyValue('rrule'); + const rrule = event.component.getFirstPropertyValue('rrule'); events.push(new EventOverview( - e.summary, - this.icalTimeToMoment(e.startDate), - e.endDate ? this.icalTimeToMoment(e.endDate) : undefined, - rrule ? rrule.freq : undefined, - e.location, - e.description, + event.summary, + this.icalTimeToMoment(event.startDate), + event.endDate ? this.icalTimeToMoment(event.endDate) : undefined, + (rrule instanceof ICAL.Recur) ? rrule.freq : undefined, + event.location, + event.description, )); } @@ -649,14 +665,25 @@ export class RunboxCalendarEvent implements CalendarEvent { return moment(time.toString()); } else { // Assemble a moment with the UTC Date() and the user's timezone - let my_timezone = time.zone && time.zone.component ? time.zone.component.getFirstPropertyValue('x-lic-location') : null; + let my_timezone: string | null = time.zone && time.zone.component ? time.zone.component.getFirstPropertyValue('x-lic-location') as string : null; my_timezone = my_timezone || this.timezone || moment.tz.guess(); const m = moment(time.toJSDate()).tz(my_timezone); return m; } } - private momentToIcalTime(input: moment.Moment, zone: ICAL.Timezone): ICAL.Time { + /** Safely get the RRULE as an ICAL.Recur, or null if missing/not a Recur instance. */ + private getRecur(): ICAL.Recur | null { + const recur = this.event.component.getFirstPropertyValue('rrule'); + return (recur && recur instanceof ICAL.Recur) ? recur : null; + } + + /** Get the account timezone from ICAL.TimezoneService, or null if unset/unregistered. */ + private getAccountTimezone(): ICAL.Timezone | null { + return this.timezone ? ICAL.TimezoneService.get(this.timezone) : null; + } + + private momentToIcalTime(input: moment.Moment, zone: ICAL.Timezone | null | undefined): ICAL.Time { // No supplied tz = new, or original didnt have one: // (Is it legit to have dates with tzs and without in same ical?) if (!zone || zone.tzid === 'floating') { @@ -672,18 +699,20 @@ export class RunboxCalendarEvent implements CalendarEvent { ical_time.isDate = this._allDay; return ical_time; } - // input is date in user timezone, convert to target timezone - const ical_tztime = ICAL.Time.fromJSDate(input.toDate()); - if (ICAL.TimezoneService.has(this.timezone)) { - ical_tztime.zone = ICAL.TimezoneService.get(this.timezone); - ical_tztime.isDate = this._allDay; - return ical_tztime.convertToZone(zone); - } else { - // Hmm this (should?) only get hit if zone wasnt loaded - // eg in tests!? - ical_tztime.zone = zone; - ical_tztime.isDate = this._allDay; - return ical_tztime; - } + // input is date in browser-local time, convert to target timezone via UTC. + // Using UTC as intermediate avoids double-conversion when browser tz + // differs from account tz (the moment's UTC representation is always correct). + const d = input.toDate(); + const ical_tztime = new ICAL.Time({ + year: d.getUTCFullYear(), + month: d.getUTCMonth() + 1, + day: d.getUTCDate(), + hour: d.getUTCHours(), + minute: d.getUTCMinutes(), + second: d.getUTCSeconds() + }); + ical_tztime.zone = ICAL.Timezone.utcTimezone; + ical_tztime.isDate = this._allDay; + return ical_tztime.convertToZone(zone); } } From 2b973a34d6ef845aea3c8bfe7bfe7b106e7e6f25 Mon Sep 17 00:00:00 2001 From: Finn Date: Thu, 16 Apr 2026 12:21:10 +0100 Subject: [PATCH 10/42] fix(calendar): make E2E timezone tests work in any browser timezone Tests were hardcoding expected display times (12:00, 14:00) that only matched BST (UTC+1) browsers. CI runs in UTC causing failures. Added expectedDisplayTime() helper that computes what the Angular date pipe would display for a given UTC time in the browser's actual timezone. --- e2e/cypress/integration/calendar-tz-bugs.ts | 13 ++++++++----- e2e/cypress/support/ics-helpers.ts | 13 +++++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/e2e/cypress/integration/calendar-tz-bugs.ts b/e2e/cypress/integration/calendar-tz-bugs.ts index d31379d09..0a69dbad7 100644 --- a/e2e/cypress/integration/calendar-tz-bugs.ts +++ b/e2e/cypress/integration/calendar-tz-bugs.ts @@ -20,7 +20,7 @@ // Reproduction tests for staging feedback on PR #1779 -import { futureDateStr, buildIcs, makeVevent, osloVtimezone, londonVtimezone } from '../support/ics-helpers'; +import { futureDateStr, buildIcs, makeVevent, osloVtimezone, londonVtimezone, expectedDisplayTime } from '../support/ics-helpers'; /** * Bug 1 (staging feedback): Creating an event with account tz = UK (London), @@ -94,11 +94,13 @@ describe('Calendar timezone bug: event time offset when account tz differs', () .should('contain', 'London Noon Meeting'); // The date pipe formats the Date (11:00 UTC) in browser-local time. - // On a BST machine: 11:00 + 1h = 12:00 + // Compute expected display time dynamically for any browser timezone. + // 12:00 London BST (UTC+1) = 11:00 UTC + const expectedLondon = expectedDisplayTime(dateStr, 11, 0); cy.get('button.calendarMonthDayEvent') .contains('London Noon Meeting') .invoke('text') - .should('match', /12:00/); + .should('match', new RegExp(expectedLondon)); }); it('should display floating time event at correct hour matching account tz', () => { @@ -131,11 +133,12 @@ describe('Calendar timezone bug: event time offset when account tz differs', () .should('contain', 'Floating 2pm Meeting'); // Floating 14:00 interpreted as London BST = 13:00 UTC. - // Date pipe on BST machine: 13:00 + 1h = 14:00 + // Compute expected display time dynamically for any browser timezone. + const expectedFloating = expectedDisplayTime(dateStr, 13, 0); cy.get('button.calendarMonthDayEvent') .contains('Floating 2pm Meeting') .invoke('text') - .should('match', /14:00/); + .should('match', new RegExp(expectedFloating)); }); }); diff --git a/e2e/cypress/support/ics-helpers.ts b/e2e/cypress/support/ics-helpers.ts index 146134ddb..54fe70e5b 100644 --- a/e2e/cypress/support/ics-helpers.ts +++ b/e2e/cypress/support/ics-helpers.ts @@ -73,6 +73,19 @@ export const osloVtimezone = [ 'END:VTIMEZONE', ].join('\r\n'); +/** + * Compute what the Angular date pipe would display for a given UTC time + * in the browser's current timezone. Returns 'HH:mm' format. + * dateStr: 'YYYYMMDD' format date string. + */ +export function expectedDisplayTime(dateStr: string, utcHour: number, utcMinute: number = 0): string { + const y = parseInt(dateStr.substring(0, 4)); + const m = parseInt(dateStr.substring(4, 6)) - 1; + const d = parseInt(dateStr.substring(6, 8)); + const date = new Date(Date.UTC(y, m, d, utcHour, utcMinute, 0)); + return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; +} + export const londonVtimezone = [ 'BEGIN:VTIMEZONE', 'TZID:Europe/London', From 9a5ff010d170d381cefa04471daa69dea080a85b Mon Sep 17 00:00:00 2001 From: Geir Thomas Andersen Date: Wed, 29 Apr 2026 00:51:33 +0200 Subject: [PATCH 11/42] feat(signup): Angular version of signup page using existing backend. --- e2e/cypress/integration/signup.ts | 45 ++ package.json | 1 + src/app/app.module.ts | 2 +- src/app/login/login.component.html | 2 +- src/app/signup/signup.component.html | 318 ++++++++++ src/app/signup/signup.component.scss | 781 ++++++++++++++++++++++++ src/app/signup/signup.component.spec.ts | 232 +++++++ src/app/signup/signup.component.ts | 319 ++++++++++ src/app/signup/signup.module.ts | 37 ++ src/build/gen-env.js | 5 + src/styles.scss | 22 + 11 files changed, 1762 insertions(+), 2 deletions(-) create mode 100644 e2e/cypress/integration/signup.ts create mode 100644 src/app/signup/signup.component.html create mode 100644 src/app/signup/signup.component.scss create mode 100644 src/app/signup/signup.component.spec.ts create mode 100644 src/app/signup/signup.component.ts create mode 100644 src/app/signup/signup.module.ts diff --git a/e2e/cypress/integration/signup.ts b/e2e/cypress/integration/signup.ts new file mode 100644 index 000000000..793b28050 --- /dev/null +++ b/e2e/cypress/integration/signup.ts @@ -0,0 +1,45 @@ +/// + +describe('Signup', () => { + it('should render the deployed Angular signup page', () => { + cy.visit('/app/signup?runbox7=1'); + + cy.location('pathname').should('eq', '/app/signup'); + cy.location('search').should('contain', 'runbox7=1'); + cy.contains('h1', 'Create a Runbox Account').should('exist'); + cy.get('form.signup-form').should('have.attr', 'action').and('match', /signup/); + cy.get('input[name="user"]').should('exist'); + cy.get('input[name="first_name"]').should('exist'); + cy.get('input[name="last_name"]').should('exist'); + cy.get('input[name="password"]').should('exist'); + cy.get('select[name="runboxDomain"]').find('option').should('have.length.greaterThan', 1); + cy.get('select[name="runboxDomain"]').find('option').then((options) => { + const domains = Array.from(options).map((option) => option.value); + expect(domains).to.include('runbox.com'); + }); + cy.get('div.captcha-host').should('exist'); + cy.contains('button.submit', 'Set up my Runbox account').should('exist'); + }); + + it('should show the public trust and transparency content', () => { + cy.visit('/app/signup?runbox7=1'); + + cy.get('header.signup-header .brand img').should('be.visible'); + cy.get('header.signup-header .brand').should('not.contain', 'Runbox 7'); + + cy.contains('.hero-panel h2', 'Privacy by business model').should('exist'); + cy.contains('.hero-panel h2', 'Hosted in Norway').should('exist'); + cy.contains('.hero-panel h2', 'Sustainable and secure').should('exist'); + cy.contains('.hero-panel h2', 'How the trial works').should('exist'); + + cy.contains('.hero-panel', 'customer email content is private').should('exist'); + cy.contains('.form-section', 'default sender name recipients will see').should('exist'); + + cy.get('.info-chip').should('have.length.at.least', 3); + cy.contains('.field-label, .field small', 'Existing email address').should('exist'); + cy.contains('.field-label, .field small', 'How did you hear about Runbox?').should('exist'); + + cy.contains('.form-actions button.submit', 'Set up my Runbox account').should('exist'); + cy.contains('a', 'Use legacy signup page').should('not.exist'); + }); +}); diff --git a/package.json b/package.json index 5a2cf1c08..2e38704ba 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "build": "node src/build/pre-build.js && ng build --configuration production --base-href=/app/ runbox7; RES=$?; node src/build/post-build.js; exit $RES", "policy": "node policy-tests/run-all.js", "test": "ng test", + "test:signup:firefox": "ng test --watch=false --browsers Firefox --include src/app/signup/signup.component.spec.ts", "lint": "ng lint", "e2e": "ng e2e", "start-e2e-server": "start-test mockserver 15000 start-use-mockserver", diff --git a/src/app/app.module.ts b/src/app/app.module.ts index bced66edb..d0d138852 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -94,6 +94,7 @@ window.addEventListener('dragover', (event) => event.preventDefault()); window.addEventListener('drop', (event) => event.preventDefault()); const routes: Routes = [ + { path: 'signup', loadChildren: () => import('./signup/signup.module').then(m => m.SignupModule) }, { path: '', canActivateChild: [RMMAuthGuardService], @@ -213,4 +214,3 @@ export class AppModule { ); } } - diff --git a/src/app/login/login.component.html b/src/app/login/login.component.html index c9d3d2425..bbc047927 100644 --- a/src/app/login/login.component.html +++ b/src/app/login/login.component.html @@ -21,7 +21,7 @@

The fastest webmail app on the planet

Runbox 7 -

Log in below or .

+

Log in below or .

diff --git a/src/app/signup/signup.component.html b/src/app/signup/signup.component.html new file mode 100644 index 000000000..1de38c2b2 --- /dev/null +++ b/src/app/signup/signup.component.html @@ -0,0 +1,318 @@ + diff --git a/src/app/signup/signup.component.scss b/src/app/signup/signup.component.scss new file mode 100644 index 000000000..6da4eae54 --- /dev/null +++ b/src/app/signup/signup.component.scss @@ -0,0 +1,781 @@ +:host { + display: block; + min-height: 100vh; + color: #0f2740; + background: + radial-gradient(circle at top left, rgba(143, 198, 255, 0.34), transparent 34%), + radial-gradient(circle at bottom right, rgba(0, 88, 153, 0.18), transparent 28%), + linear-gradient(180deg, #f5f9fc 0%, #dfeaf3 100%); +} + +.signup-shell { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.signup-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + padding: 1rem 1.5rem; + background-image: linear-gradient(145deg, #0068b7, #003156); + color: #fff; + box-shadow: 0 8px 20px rgba(0, 25, 46, 0.12); +} + +.brand { + display: inline-flex; + align-items: center; + color: inherit; + text-decoration: none; +} + +.brand img { + height: 34px; + width: auto; +} + +.header-tagline { + margin: 0; + color: rgba(255, 255, 255, 0.9); + font-size: 1rem; + font-weight: 600; + letter-spacing: 0.01em; +} + +.footer-links a:hover { + text-decoration: underline; +} + +.signup-main { + width: min(1380px, calc(100% - 2rem)); + margin: 0 auto; + padding: 1.5rem 0 2rem; + display: grid; + grid-template-columns: minmax(280px, 420px) minmax(0, 1fr); + gap: 1.5rem; + align-items: start; + flex: 1 0 auto; +} + +.hero-panel, +.form-panel { + border: 1px solid rgba(112, 145, 176, 0.28); + border-radius: 8px; + overflow: hidden; + box-shadow: 0 18px 48px rgba(9, 33, 58, 0.1); +} + +.hero-panel { + display: grid; + background: linear-gradient(180deg, #0068b7 0%, #003156 100%); + color: #fff; +} + +.hero-copy { + padding: 2rem 1.75rem 1.5rem; +} + +.eyebrow { + margin: 0 0 0.75rem; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: rgba(255, 255, 255, 0.72); +} + +.hero-copy h1 { + margin: 0; + font-size: clamp(2rem, 3vw, 3.2rem); + line-height: 1.02; +} + +.hero-text { + margin: 1rem 0 0; + font-size: 1.04rem; + line-height: 1.6; + color: rgba(255, 255, 255, 0.88); +} + +.hero-subtext { + margin: 1rem 0 0; + max-width: 40rem; + font-size: 0.96rem; + line-height: 1.6; + color: rgba(255, 255, 255, 0.78); +} + +.hero-notes { + display: grid; + gap: 0; + background: transparent; +} + +.note { + padding: 1.15rem 1.75rem; + background: transparent; + border-top: 1px solid rgba(255, 255, 255, 0.14); +} + +.note h2 { + margin: 0 0 0.4rem; + font-size: 1rem; +} + +.note p { + margin: 0; + line-height: 1.5; + color: rgba(255, 255, 255, 0.78); +} + +.form-panel { + background: rgba(255, 255, 255, 0.92); + backdrop-filter: blur(4px); +} + +.form-heading { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 0; + align-items: center; + padding: 1.4rem 1.5rem; + border-bottom: 1px solid #d9e4ef; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.95), rgba(240, 246, 252, 0.92)); +} + +.form-heading h2 { + margin: 0; + font-size: 1.7rem; +} + +.form-heading p { + margin: 0.35rem 0 0; + color: #486175; + line-height: 1.5; +} + +.signup-form { + padding: 1.4rem 1.5rem 1.6rem; +} + +.form-section + .form-section { + margin-top: 1.35rem; +} + +.form-section { + padding-top: 1.35rem; + border-top: 1px solid #dde7f0; +} + +.form-section:first-child { + padding-top: 0; + border-top: 0; +} + +.section-head { + margin-bottom: 0.9rem; +} + +.section-head h3 { + margin: 0; + font-size: 1.08rem; +} + +.section-head p { + margin: 0.28rem 0 0; + color: #5a7187; + line-height: 1.5; +} + +.choice-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.85rem; +} + +.choice-card { + display: grid; + gap: 0.32rem; + padding: 1rem 1rem 1rem 2.8rem; + position: relative; + border: 1px solid #bfd0e1; + border-radius: 8px; + background: #f8fbfe; + cursor: pointer; +} + +.choice-card input[type='radio'] { + position: absolute; + top: 1.08rem; + left: 1rem; + margin: 0; +} + +.choice-card.active { + border-color: #0068b7; + background: linear-gradient(180deg, #eef7ff 0%, #f8fbff 100%); + box-shadow: inset 0 0 0 1px rgba(0, 104, 183, 0.18); +} + +.choice-title { + font-weight: 700; +} + +.choice-copy { + color: #587085; + line-height: 1.45; + font-weight: 400; +} + +.field-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.9rem 1rem; +} + +.field-grid.single, +.field.field-wide { + grid-column: 1 / -1; +} + +.field { + display: grid; + gap: 0.38rem; + align-content: start; +} + +.field > span { + font-weight: 700; +} + +.field-label { + display: inline-flex; + align-items: center; + gap: 0.45rem; + flex-wrap: wrap; +} + +.field > small { + color: #637b90; + font-size: 0.82rem; +} + +.field.is-invalid > span, +.checkbox-row.is-invalid span { + color: #a12020; +} + +.field > small .info-chip { + margin-left: 0.3rem; +} + +.info-chip { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.15rem; + height: 1.15rem; + padding: 0; + border: 0; + border-radius: 999px; + background: #d9ebf8; + color: #005897; + font: inherit; + font-size: 0.76rem; + font-weight: 700; + line-height: 1; + cursor: help; +} + +.info-popover { + position: absolute; + left: 50%; + top: calc(100% + 0.55rem); + z-index: 5; + width: min(18rem, 80vw); + padding: 0.7rem 0.8rem; + border-radius: 8px; + background: #0f2740; + color: #fff; + font-size: 0.82rem; + font-weight: 400; + line-height: 1.5; + text-align: left; + box-shadow: 0 16px 32px rgba(9, 33, 58, 0.22); + opacity: 0; + pointer-events: none; + transform: translate(-50%, 0.2rem); + transition: opacity 120ms ease, transform 120ms ease; +} + +.info-popover::before { + content: ''; + position: absolute; + left: 50%; + top: -0.35rem; + width: 0.7rem; + height: 0.7rem; + background: #0f2740; + transform: translateX(-50%) rotate(45deg); +} + +.info-chip:hover .info-popover, +.info-chip:focus-visible .info-popover, +.info-chip:active .info-popover { + opacity: 1; + transform: translate(-50%, 0); +} + +.info-chip:focus-visible { + outline: 2px solid #0068b7; + outline-offset: 2px; +} + +.field input, +.field select { + width: 100%; + min-width: 0; + padding: 0.78rem 0.9rem; + border: 1px solid #9eb5ca; + border-radius: 8px; + background: #fff; + font: inherit; + color: #0f2740; + box-sizing: border-box; +} + +.field input:focus, +.field select:focus { + outline: 0; + border-color: #0068b7; + box-shadow: 0 0 0 3px rgba(0, 104, 183, 0.12); +} + +.field.is-invalid input, +.field.is-invalid select, +.checkbox-row.is-invalid input { + border-color: #d04848; + background: #fff7f7; +} + +.field.is-invalid input:focus, +.field.is-invalid select:focus, +.checkbox-row.is-invalid input:focus { + border-color: #c23030; + box-shadow: 0 0 0 3px rgba(210, 72, 72, 0.14); +} + +.strength-meter { + display: grid; + grid-template-columns: auto minmax(120px, 220px) auto; + align-items: center; + gap: 0.8rem; + margin-top: 0.9rem; +} + +.strength-meter span, +.strength-meter strong { + white-space: nowrap; +} + +.strength-meter progress { + width: 100%; + height: 10px; +} + +.captcha-box { + padding: 1rem; + border-radius: 8px; + background: #f6fafd; + border: 1px solid #d6e2ec; + overflow-x: auto; +} + +.captcha-host { + min-height: 78px; +} + +.captcha-host.is-hidden { + display: none; +} + +.captcha-host:empty::before { + content: 'Loading CAPTCHA...'; + display: inline-block; + color: #587085; + font-size: 0.92rem; +} + +.captcha-missing { + margin: 0; + padding: 0.8rem 0.9rem; + border: 1px solid #d6b087; + background: #fff3e4; + border-radius: 8px; + line-height: 1.5; +} + +.policy-block { + background: linear-gradient(180deg, #f8fbfe 0%, #f2f7fb 100%); + padding: 1.25rem; + border: 1px solid #dce6ef; + border-radius: 8px; +} + +.policy-block .section-head { + margin-bottom: 1rem; +} + +.news-choice { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.9rem 1.2rem; +} + +.news-choice > span { + font-weight: 700; +} + +.inline-options { + display: flex; + flex-wrap: wrap; + gap: 1rem; +} + +.inline-options label, +.checkbox-row { + display: flex; + align-items: flex-start; + gap: 0.65rem; +} + +.inline-options label { + font-weight: 600; +} + +.inline-options input, +.checkbox-row input { + margin: 0.15rem 0 0; + flex: 0 0 auto; +} + +.checkbox-row { + margin-top: 1rem; + line-height: 1.55; +} + +.form-error { + margin: 1.1rem 0 0; + padding: 0.85rem 1rem; + border-radius: 8px; + border: 1px solid #d79a9a; + background: #fff2f2; + color: #8b1d1d; + line-height: 1.5; + font-weight: 600; +} + +.field-error { + display: block; + color: #a12020; + font-size: 0.82rem; + line-height: 1.4; +} + +.captcha-error { + margin-top: 0.75rem; +} + +.form-actions { + display: flex; + align-items: center; + margin-top: 1.5rem; +} + +.submit { + border: 0; + border-radius: 4px; + min-width: 132px; + min-height: 42px; + background: #01579b; + color: #fff; + padding: 0 1.7rem; + font: inherit; + font-weight: 700; + font-size: 1rem; + cursor: pointer; + line-height: 36px; + box-shadow: + 0 3px 1px -2px rgba(0, 0, 0, 0.2), + 0 2px 2px 0 rgba(0, 0, 0, 0.14), + 0 1px 5px 0 rgba(0, 0, 0, 0.12); + transition: background-color 120ms ease, box-shadow 120ms ease; +} + +.submit:hover { + background: #0068b7; + box-shadow: + 0 4px 5px -2px rgba(0, 0, 0, 0.2), + 0 7px 10px 1px rgba(0, 0, 0, 0.14), + 0 2px 16px 1px rgba(0, 0, 0, 0.12); +} + +.submit.is-submitting { + opacity: 0.84; + cursor: wait; +} + +.honeypot { + position: absolute; + left: -10000px; + top: auto; + width: 1px; + height: 1px; + overflow: hidden; +} + +.signup-footer { + padding: 1.2rem 1.25rem 1.8rem; + background-image: linear-gradient(170deg, #014f89, #001e35); + color: rgba(255, 255, 255, 0.82); + text-align: center; +} + +.footer-links { + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 1rem 1.2rem; + margin-bottom: 0.75rem; +} + +.footer-links a { + color: rgba(255, 255, 255, 0.86); + text-decoration: none; +} + +.signup-footer p { + margin: 0; + font-size: 0.92rem; +} + +@media (max-width: 1100px) { + .signup-main { + grid-template-columns: 1fr; + } + + .hero-panel { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 900px) { + .signup-header { + padding: 0.9rem 1.15rem; + } + + .header-tagline { + font-size: 0.94rem; + } + + .signup-main { + width: min(100% - 1.5rem, 1380px); + gap: 1.1rem; + } + + .hero-copy, + .note, + .signup-form, + .form-heading { + padding-left: 1.2rem; + padding-right: 1.2rem; + } + + .form-heading { + grid-template-columns: 1fr; + } + + .choice-grid, + .field-grid { + grid-template-columns: 1fr; + } + + .strength-meter { + grid-template-columns: 1fr; + justify-items: start; + } + + .captcha-box { + padding: 0.9rem; + } +} + +@media (max-width: 720px) { + .signup-header { + flex-direction: column; + align-items: flex-start; + gap: 0.55rem; + } + + .signup-main { + width: min(100% - 1rem, 1380px); + padding-top: 0.75rem; + padding-bottom: 1.2rem; + gap: 1rem; + } + + .hero-panel { + grid-template-columns: 1fr; + } + + .hero-copy, + .note, + .signup-form, + .form-heading { + padding-left: 1rem; + padding-right: 1rem; + } + + .news-choice { + align-items: flex-start; + flex-direction: column; + } + + .form-actions { + align-items: stretch; + } + + .submit { + width: 100%; + min-height: 46px; + } + + .info-popover { + left: 0; + transform: translate(0, 0.2rem); + } + + .info-popover::before { + left: 1rem; + transform: rotate(45deg); + } + + .info-chip:hover .info-popover, + .info-chip:focus-visible .info-popover, + .info-chip:active .info-popover { + transform: translate(0, 0); + } +} + +@media (max-width: 560px) { + .signup-header { + padding: 0.85rem 1rem 0.9rem; + } + + .brand img { + height: 28px; + } + + .header-tagline { + font-size: 0.88rem; + line-height: 1.4; + } + + .signup-footer p, + .footer-links a { + font-size: 0.9rem; + } + + .hero-copy h1 { + font-size: clamp(1.8rem, 9vw, 2.4rem); + line-height: 1.08; + } + + .hero-text, + .hero-subtext, + .note p, + .section-head p { + font-size: 0.95rem; + } + + .signup-form, + .form-heading, + .hero-copy, + .note { + padding-left: 0.9rem; + padding-right: 0.9rem; + } + + .field input, + .field select { + padding: 0.72rem 0.82rem; + font-size: 16px; + } + + .policy-block { + padding: 1rem; + } + + .inline-options { + gap: 0.8rem; + } + + .captcha-box { + padding: 0.75rem; + } + + .submit { + padding: 0 1.2rem; + } + + .signup-footer { + padding: 1rem 0.9rem 1.4rem; + } +} + +@media (max-width: 420px) { + .signup-main { + width: calc(100% - 0.75rem); + gap: 0.85rem; + } + + .hero-copy h1 { + font-size: 1.85rem; + } + + .hero-text, + .hero-subtext, + .note p, + .form-heading p, + .section-head p { + font-size: 0.92rem; + } + + .form-heading { + padding-top: 1.15rem; + padding-bottom: 1.15rem; + } + + .signup-form { + padding-top: 1.1rem; + padding-bottom: 1.25rem; + } + + .note { + padding-top: 1rem; + padding-bottom: 1rem; + } + + .policy-block { + padding: 1rem; + } + + .footer-links { + gap: 0.75rem 1rem; + } +} diff --git a/src/app/signup/signup.component.spec.ts b/src/app/signup/signup.component.spec.ts new file mode 100644 index 000000000..c8155746e --- /dev/null +++ b/src/app/signup/signup.component.spec.ts @@ -0,0 +1,232 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2026 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule, NgForm, NgModel } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { ActivatedRoute, convertToParamMap } from '@angular/router'; +import { BehaviorSubject } from 'rxjs'; +import { SignupComponent } from './signup.component'; + +describe('SignupComponent', () => { + let component: SignupComponent; + let fixture: ComponentFixture; + let httpMock: HttpTestingController; + let queryParamMap$: BehaviorSubject>; + + beforeEach(async () => { + queryParamMap$ = new BehaviorSubject(convertToParamMap({ runbox7: '1' })); + + await TestBed.configureTestingModule({ + imports: [FormsModule, HttpClientTestingModule], + declarations: [SignupComponent], + providers: [ + { + provide: ActivatedRoute, + useValue: { + queryParamMap: queryParamMap$.asObservable(), + }, + }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(SignupComponent); + component = fixture.componentInstance; + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + function stubCaptchaInitialization(loadResult = false): jasmine.Spy { + return spyOn(component, 'loadHCaptchaScript').and.resolveTo(loadResult); + } + + function flushLegacyMetadata(html?: string): void { + const request = httpMock.expectOne('/signup?legacy=1&runbox7=1'); + expect(request.request.method).toBe('GET'); + request.flush(html || ` + + +
+ +
+
+ + + `); + } + + async function initComponent(html?: string, loadResult = false): Promise { + stubCaptchaInitialization(loadResult); + fixture.detectChanges(); + flushLegacyMetadata(html); + await fixture.whenStable(); + fixture.detectChanges(); + } + + function getForm(): NgForm { + return fixture.debugElement.query(By.css('form')).injector.get(NgForm); + } + + function setInputValue(selector: string, value: string): HTMLInputElement { + const input = fixture.nativeElement.querySelector(selector) as HTMLInputElement; + input.value = value; + input.dispatchEvent(new Event('input', { bubbles: true })); + return input; + } + + function setCheckboxValue(selector: string, checked: boolean): HTMLInputElement { + const input = fixture.nativeElement.querySelector(selector) as HTMLInputElement; + input.checked = checked; + input.dispatchEvent(new Event('change', { bubbles: true })); + return input; + } + + async function fillRequiredFields(): Promise { + setInputValue('input[name="first_name"]', 'Joe'); + setInputValue('input[name="last_name"]', 'Bond'); + setInputValue('input[name="user"]', 'joebond'); + setInputValue('input[name="password"]', 'S3cret!Pass'); + setInputValue('input[name="email_alternative"]', 'joe@example.com'); + setCheckboxValue('input[name="tos_accepted"]', true); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + } + + it('loads signup metadata from the legacy signup page', async () => { + await initComponent(); + + expect(component.signupAction).toBe('/mail/signup'); + expect(component.hCaptchaSiteKey).toBe('test-site-key'); + expect(component.runboxDomains).toEqual(['runbox.com', 'runbox.no', 'rbx.email']); + expect(component.runboxDomain).toBe('runbox.com'); + }); + + it('applies query parameters during initialization', async () => { + queryParamMap$.next(convertToParamMap({ + accountType: 'business', + domainType: 'user', + account_number: '12345', + runbox7: '7', + })); + + await initComponent(); + + expect(component.accountType).toBe('business'); + expect(component.domainType).toBe('user'); + expect(component.accountNumber).toBe('12345'); + expect(component.runbox7).toBe('7'); + }); + + it('keeps safe defaults if legacy metadata cannot be fetched', async () => { + stubCaptchaInitialization(false); + fixture.detectChanges(); + + const request = httpMock.expectOne('/signup?legacy=1&runbox7=1'); + request.flush('backend unavailable', { status: 500, statusText: 'Server Error' }); + + await fixture.whenStable(); + fixture.detectChanges(); + + expect(component.signupAction).toBe('/signup'); + expect(component.runboxDomains).toEqual(['runbox.com', 'runbox.no']); + expect(component.hCaptchaSiteKey).toBe(''); + expect(component.hCaptchaError).toContain('CAPTCHA is temporarily unavailable'); + }); + + it('shows field-level validation feedback after submit', async () => { + await initComponent(); + + const formElement = fixture.nativeElement.querySelector('form') as HTMLFormElement; + const focusSpy = spyOn(component, 'focusFirstInvalidField'); + + component.onSubmit(getForm(), formElement); + fixture.detectChanges(); + + const fieldErrors = Array.from(fixture.nativeElement.querySelectorAll('.field-error')) + .map((el: HTMLElement) => el.textContent?.trim()); + + expect(component.submitError).toBe('Complete the required fields before continuing.'); + expect(focusSpy).toHaveBeenCalledWith(formElement); + expect(fieldErrors).toContain('Enter your first name.'); + expect(fieldErrors).toContain('Enter your last name.'); + expect(fieldErrors).toContain('Choose a username for your mailbox.'); + expect(fieldErrors).toContain('Enter a password for your account.'); + expect(fieldErrors).toContain('Enter an email address for recovery and account notices.'); + expect(fieldErrors).toContain('You must accept the terms to create an account.'); + }); + + it('shows a field-level validation error for an invalid custom domain', async () => { + await initComponent(); + + component.domainType = 'user'; + fixture.detectChanges(); + + const domainControl = fixture.debugElement.query(By.css('input[name="userdomain"]')).injector.get(NgModel); + setInputValue('input[name="userdomain"]', 'invalid domain'); + fixture.detectChanges(); + + expect(component.showFieldError(domainControl, getForm())).toBeTrue(); + expect(fixture.nativeElement.textContent).toContain('Enter a valid domain such as example.com.'); + }); + + it('blocks submit if captcha is missing even when required fields are valid', async () => { + await initComponent(); + await fillRequiredFields(); + + const formElement = fixture.nativeElement.querySelector('form') as HTMLFormElement; + const submitSpy = spyOn(formElement, 'submit'); + + component.onSubmit(getForm(), formElement); + fixture.detectChanges(); + + expect(component.showCaptchaValidationError).toBeTrue(); + expect(component.submitError).toBe('Complete the CAPTCHA verification before submitting.'); + expect(submitSpy).not.toHaveBeenCalled(); + }); + + it('submits the native form when validation and captcha both pass', async () => { + await initComponent(); + await fillRequiredFields(); + + const formElement = fixture.nativeElement.querySelector('form') as HTMLFormElement; + const captchaResponse = document.createElement('textarea'); + captchaResponse.name = 'h-captcha-response'; + captchaResponse.value = 'captcha-token'; + formElement.appendChild(captchaResponse); + const submitSpy = spyOn(formElement, 'submit'); + + component.onSubmit(getForm(), formElement); + + expect(component.submitInProgress).toBeTrue(); + expect(submitSpy).toHaveBeenCalled(); + expect(component.submitError).toBe(''); + expect(component.showCaptchaValidationError).toBeFalse(); + }); +}); diff --git a/src/app/signup/signup.component.ts b/src/app/signup/signup.component.ts new file mode 100644 index 000000000..67a31bcdd --- /dev/null +++ b/src/app/signup/signup.component.ts @@ -0,0 +1,319 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2026 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + +import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { NgForm, NgModel } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; +import { environment } from '../../environments/environment'; + +type AccountType = 'person' | 'business'; +type DomainType = 'runbox' | 'user'; + +@Component({ + selector: 'app-signup', + templateUrl: './signup.component.html', + styleUrls: ['./signup.component.scss'], +}) +export class SignupComponent implements OnInit, AfterViewInit, OnDestroy { + @ViewChild('captchaContainer') captchaContainer?: ElementRef; + + accountType: AccountType = 'person'; + domainType: DomainType = 'runbox'; + + user = ''; + userDomain = ''; + runboxDomain = 'runbox.com'; + firstName = ''; + lastName = ''; + company = ''; + password = ''; + emailAlternative = ''; + phoneNumberCellular = ''; + referrer = ''; + sendNewsOffers = ''; + tosAccepted = false; + passwordStrength = 0; + accountNumber = ''; + runbox7 = '1'; + timezone = 'UTC'; + signupAction = '/signup'; + + runboxDomains = ['runbox.com', 'runbox.no']; + readonly referrers = [ + 'Advertisement', + 'Friend or family', + 'News media', + 'Review website', + 'Search engine', + 'Social media', + 'Other', + ]; + + hCaptchaSiteKey = environment.SIGNUP_HCAPTCHA_SITE_KEY || ''; + hCaptchaError = ''; + submitError = ''; + submitInProgress = false; + showCaptchaValidationError = false; + + private hCaptchaWidgetId: string | null = null; + private hCaptchaReady = false; + private nativeSubmitting = false; + private pendingCaptchaRender = false; + + constructor( + private route: ActivatedRoute, + private http: HttpClient, + ) {} + + ngOnInit(): void { + document.body.classList.add('signup-page'); + document.getElementById('main')?.classList.add('signup-page-shell'); + + const host = window?.location?.hostname || ''; + if (host.endsWith('.no')) { + this.runboxDomain = 'runbox.no'; + } + const resolvedTz = Intl.DateTimeFormat().resolvedOptions().timeZone; + if (resolvedTz) { + this.timezone = resolvedTz; + } + this.route.queryParamMap.subscribe((params) => { + const accountType = params.get('accountType'); + if (accountType === 'business' || accountType === 'person') { + this.accountType = accountType; + } + const domainType = params.get('domainType'); + if (domainType === 'user' || domainType === 'runbox') { + this.domainType = domainType; + } + this.accountNumber = params.get('account_number') || params.get('accountNumber') || ''; + this.runbox7 = params.get('runbox7') || '1'; + }); + + void this.initializeHCaptcha(); + } + + ngAfterViewInit(): void { + if (this.pendingCaptchaRender) { + this.renderHCaptcha(); + } + } + + ngOnDestroy(): void { + document.body.classList.remove('signup-page'); + document.getElementById('main')?.classList.remove('signup-page-shell'); + } + + onPasswordChange(): void { + let score = 0; + if (this.password.length >= 8) { + score++; + } + if (/[a-z]/.test(this.password) && /[A-Z]/.test(this.password)) { + score++; + } + if (/\d/.test(this.password)) { + score++; + } + if (/[^A-Za-z0-9]/.test(this.password)) { + score++; + } + this.passwordStrength = score; + } + + onSubmit(form: NgForm, formElement: HTMLFormElement): void { + this.submitError = ''; + this.showCaptchaValidationError = false; + + if (this.nativeSubmitting) { + return; + } + + if (!form.valid) { + this.submitError = 'Complete the required fields before continuing.'; + this.focusFirstInvalidField(formElement); + return; + } + + if (!this.hCaptchaSiteKey) { + this.submitError = 'CAPTCHA is unavailable right now. Use the legacy signup page or try again shortly.'; + return; + } + + if (!this.hasCaptchaResponse(formElement)) { + this.showCaptchaValidationError = true; + this.submitError = 'Complete the CAPTCHA verification before submitting.'; + this.captchaContainer?.nativeElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); + return; + } + + this.submitInProgress = true; + this.nativeSubmitting = true; + formElement.submit(); + } + + showFieldError(control?: NgModel | null, form?: NgForm): boolean { + if (!control) { + return false; + } + + return control.invalid && (control.touched || control.dirty || Boolean(form?.submitted)); + } + + private async initializeHCaptcha(): Promise { + await this.loadLegacySignupMetadata(); + + if (!this.hCaptchaSiteKey) { + this.hCaptchaError = 'CAPTCHA is temporarily unavailable. Please use the legacy signup page below.'; + return; + } + + const captchaLoaded = await this.loadHCaptchaScript(); + if (!captchaLoaded) { + return; + } + + this.hCaptchaReady = true; + this.renderHCaptcha(); + } + + private loadLegacySignupMetadata(): Promise { + return new Promise((resolve) => { + this.http + .get('/signup?legacy=1&runbox7=1', { responseType: 'text' }) + .subscribe({ + next: (html) => { + const doc = new DOMParser().parseFromString(html, 'text/html'); + const legacyWidget = doc.querySelector('.h-captcha'); + const legacyForm = doc.querySelector('form[name="signup"], form[action*="signup"]'); + const legacyDomains = Array.from( + doc.querySelectorAll('select[name="runboxDomain"] option'), + ) + .map((option) => option.value.trim()) + .filter((domain, index, domains) => Boolean(domain) && domains.indexOf(domain) === index); + + this.hCaptchaSiteKey = legacyWidget?.getAttribute('data-sitekey') || this.hCaptchaSiteKey; + this.signupAction = legacyForm?.getAttribute('action') || this.signupAction; + if (legacyDomains.length > 0) { + this.runboxDomains = legacyDomains; + if (!this.runboxDomains.includes(this.runboxDomain)) { + this.runboxDomain = this.runboxDomains[0]; + } + } + resolve(); + }, + error: () => resolve(), + }); + }); + } + + private loadHCaptchaScript(): Promise { + return new Promise((resolve, reject) => { + const existingScript = document.querySelector('script[data-runbox-hcaptcha="1"]'); + if (existingScript) { + if ((window as WindowWithHCaptcha).hcaptcha) { + resolve(true); + return; + } + + const pollForHCaptcha = window.setInterval(() => { + if ((window as WindowWithHCaptcha).hcaptcha) { + window.clearInterval(pollForHCaptcha); + resolve(true); + } + }, 100); + + existingScript.addEventListener('load', () => { + window.clearInterval(pollForHCaptcha); + resolve(true); + }, { once: true }); + existingScript.addEventListener('error', () => reject(new Error('Failed to load hCaptcha.')), { once: true }); + return; + } + + const script = document.createElement('script'); + script.src = 'https://hcaptcha.com/1/api.js?render=explicit'; + script.async = true; + script.defer = true; + script.setAttribute('data-runbox-hcaptcha', '1'); + script.addEventListener('load', () => resolve(true), { once: true }); + script.addEventListener('error', () => reject(new Error('Failed to load hCaptcha.')), { once: true }); + document.body.appendChild(script); + }).catch((): boolean => { + this.hCaptchaError = 'CAPTCHA could not be loaded. Please use the legacy signup page below.'; + return false; + }); + } + + private renderHCaptcha(): void { + if (!this.hCaptchaReady || !this.hCaptchaSiteKey) { + return; + } + + const container = this.captchaContainer?.nativeElement; + const hcaptcha = (window as WindowWithHCaptcha).hcaptcha; + if (!container || !hcaptcha) { + this.pendingCaptchaRender = true; + window.setTimeout(() => this.renderHCaptcha(), 0); + return; + } + + this.pendingCaptchaRender = false; + + if (this.hCaptchaWidgetId !== null) { + return; + } + + this.hCaptchaWidgetId = hcaptcha.render(container, { + sitekey: this.hCaptchaSiteKey, + callback: () => { + this.hCaptchaError = ''; + this.submitError = ''; + }, + 'expired-callback': () => { + this.hCaptchaError = 'CAPTCHA expired. Complete it again before submitting.'; + }, + 'error-callback': () => { + this.hCaptchaError = 'CAPTCHA failed to load correctly. Try again or use the legacy signup page.'; + }, + }); + } + + private hasCaptchaResponse(formElement: HTMLFormElement): boolean { + const response = formElement.querySelector('textarea[name="h-captcha-response"], input[name="h-captcha-response"]'); + return Boolean(response?.value?.trim()); + } + + private focusFirstInvalidField(formElement: HTMLFormElement): void { + const firstInvalidField = formElement.querySelector( + 'input.ng-invalid, select.ng-invalid, textarea.ng-invalid, input:invalid, select:invalid, textarea:invalid', + ); + firstInvalidField?.focus(); + firstInvalidField?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } +} + +interface HCaptchaApi { + render(container: string | HTMLElement, params: Record): string; +} + +interface WindowWithHCaptcha extends Window { + hcaptcha?: HCaptchaApi; +} diff --git a/src/app/signup/signup.module.ts b/src/app/signup/signup.module.ts new file mode 100644 index 000000000..53daff9ac --- /dev/null +++ b/src/app/signup/signup.module.ts @@ -0,0 +1,37 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2026 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { SignupComponent } from './signup.component'; + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + RouterModule.forChild([ + { path: '', component: SignupComponent }, + ]), + ], + declarations: [SignupComponent], +}) +export class SignupModule {} + diff --git a/src/build/gen-env.js b/src/build/gen-env.js index 8db3a977b..ffc3ceeea 100644 --- a/src/build/gen-env.js +++ b/src/build/gen-env.js @@ -13,6 +13,7 @@ process.env.BUILD_TIMESTAMP ??= new Date().toJSON() const env = [ ['BUILD_TIMESTAMP', assertString], ['SENTRY_DSN', tryCatch(assertString, orNull)], + ['SIGNUP_HCAPTCHA_SITE_KEY', tryCatch(assertString, orEmptyString)], ] function assertString(input) { @@ -37,6 +38,10 @@ function orNull () { return null } +function orEmptyString () { + return '' +} + fs.writeFileSync('src/environments/env.ts', ` /* eslint-disable @typescript-eslint/quotes */ diff --git a/src/styles.scss b/src/styles.scss index ed7a85188..553d71bee 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -404,6 +404,28 @@ mat-grid-tile.tableTitle { justify-content: center; } +body.signup-page { + height: auto; + min-height: 100%; + overflow-y: auto; + overscroll-behavior: auto; +} + +#main.signup-page-shell { + position: static; + width: 100%; + height: auto; + min-height: 100vh; + overflow: visible; + display: block; +} + +body.signup-page app-rmm { + display: block !important; + width: 100% !important; + min-height: 100vh; +} + /* Snackbar */ .mat-snack-bar-container { From 178ebf1ec35c17acb573106981d0579e009ae7f8 Mon Sep 17 00:00:00 2001 From: Finn Date: Fri, 1 May 2026 04:25:38 +0100 Subject: [PATCH 12/42] fix(calendar): fix all-day date shift, citadel-path TZID, and all-day write path Three timezone-related bugs fixed in runbox-calendar-event.ts: 1. All-day events displayed one day earlier because toJSDate() shifted midnight UTC+offset to previous-day UTC. Fix: return noon UTC date in convertIcalTimeToDate() for isDate=true. 2. Citadel-path TZIDs (/citadel.org/.../Europe/Oslo) caused wrong display hour because moment-timezone couldn't resolve them. Fix: extract IANA name from path-style TZIDs in resolveTimezoneName() helper. 3. Multi-day all-day events stored wrong end date because momentToIcalTime() used UTC date parts for new events. Fix: use local date parts for all-day events and set zone to account timezone. Also consolidates e2e timezone tests into calendar-timezone.ts, fixes fragile getHours mock and day-cell selector ambiguity. --- e2e/cypress/integration/calendar-timezone.ts | 195 +++++++++++- e2e/cypress/integration/calendar-tz-bugs.ts | 298 ------------------ .../runbox-calendar-event.spec.ts | 144 +++++++++ src/app/calendar-app/runbox-calendar-event.ts | 41 ++- 4 files changed, 378 insertions(+), 300 deletions(-) delete mode 100644 e2e/cypress/integration/calendar-tz-bugs.ts diff --git a/e2e/cypress/integration/calendar-timezone.ts b/e2e/cypress/integration/calendar-timezone.ts index 7cf69268f..720cdd201 100644 --- a/e2e/cypress/integration/calendar-timezone.ts +++ b/e2e/cypress/integration/calendar-timezone.ts @@ -18,7 +18,7 @@ // along with Runbox 7. If not, see . // ---------- END RUNBOX LICENSE ---------- -import { futureDateStr, buildIcs, makeVevent, osloVtimezone } from '../support/ics-helpers'; +import { futureDateStr, buildIcs, makeVevent, osloVtimezone, londonVtimezone, expectedDisplayTime } from '../support/ics-helpers'; describe('Calendar timezone handling', () => { beforeEach(() => { @@ -138,4 +138,197 @@ describe('Calendar timezone handling', () => { .should('contain', 'Imported UTC Event'); doImport(); }); + + // --- Timezone bug reproduction tests --- + + it('should display London TZID event at correct hour for London account', () => { + cy.request('/rest/e2e/setTimezone_Europe/London'); + const dateStr = futureDateStr(3); + + // Event at 12:00 London (BST = UTC+1 in summer) = 11:00 UTC + const ics = buildIcs([ + makeVevent( + `TZID=Europe/London:${dateStr}T120000`, + `TZID=Europe/London:${dateStr}T130000`, + 'London Noon Meeting', 'tz-london-001'), + ], londonVtimezone); + + cy.request({ + method: 'POST', + url: '/rest/e2e/addEvent', + body: { id: 'mock cal/tz-london-001', ical: ics, calendar: 'mock cal' }, + }); + + cy.visit('/calendar'); + cy.get('.calendarListItem').should('have.length', 1); + cy.get('button.calendarMonthDayEvent').should('contain', 'London Noon Meeting'); + + const expected = expectedDisplayTime(dateStr, 11, 0); + cy.get('button.calendarMonthDayEvent') + .contains('London Noon Meeting') + .invoke('text') + .should('match', new RegExp(expected)); + + cy.request('/rest/e2e/resetTimezone'); + }); + + it('should display floating time event at correct hour matching account tz', () => { + cy.request('/rest/e2e/setTimezone_Europe/London'); + const dateStr = futureDateStr(3); + + // Floating time 14:00 (no TZID) — interpreted as London BST = 13:00 UTC + const ics = buildIcs([ + makeVevent(`${dateStr}T140000`, `${dateStr}T150000`, 'Floating 2pm Meeting', 'tz-floating-001'), + ]); + + cy.request({ + method: 'POST', + url: '/rest/e2e/addEvent', + body: { id: 'mock cal/tz-floating-001', ical: ics, calendar: 'mock cal' }, + }); + + cy.visit('/calendar'); + cy.get('.calendarListItem').should('have.length', 1); + cy.get('button.calendarMonthDayEvent').should('contain', 'Floating 2pm Meeting'); + + const expected = expectedDisplayTime(dateStr, 13, 0); + cy.get('button.calendarMonthDayEvent') + .contains('Floating 2pm Meeting') + .invoke('text') + .should('match', new RegExp(expected)); + + cy.request('/rest/e2e/resetTimezone'); + }); + + it('should show different displayed hour after timezone change', () => { + const dateStr = futureDateStr(3); + + // Floating time 12:00. Account timezone determines interpretation: + // Oslo CEST = 12:00 local = 10:00 UTC, London BST = 12:00 local = 11:00 UTC. + // These produce different local hours in any browser timezone. + const ics = buildIcs([ + makeVevent(`${dateStr}T120000`, `${dateStr}T130000`, 'Floating Noon', 'tz-changeme'), + ]); + + cy.request({ + method: 'POST', + url: '/rest/e2e/addEvent', + body: { id: 'mock cal/tz-changeme', ical: ics, calendar: 'mock cal' }, + }); + + // View with default Oslo timezone + cy.visit('/calendar'); + cy.get('.calendarListItem').should('have.length', 1); + + cy.get('button.calendarMonthDayEvent') + .contains('Floating Noon') + .invoke('text') + .then(osloText => { + const osloHour = osloText.match(/(\d+):\d+/); + + // Switch to London timezone + cy.request('/rest/e2e/setTimezone_Europe/London'); + cy.visit('/calendar'); + cy.get('.calendarListItem').should('have.length', 1); + + cy.get('button.calendarMonthDayEvent') + .contains('Floating Noon') + .invoke('text') + .then(londonText => { + const londonHour = londonText.match(/(\d+):\d+/); + expect(londonHour?.[1]).to.not.equal(osloHour?.[1], + 'Displayed hour should change after timezone change'); + }); + }); + + cy.request('/rest/e2e/resetTimezone'); + }); + + it('should display 12pm Oslo event at correct hour in month view', () => { + const dateStr = futureDateStr(3); + + // Event at 12:00 Oslo (CEST = UTC+2 in summer) = 10:00 UTC + const ics = buildIcs([ + makeVevent( + `TZID=/citadel.org/20210210_1/Europe/Oslo:${dateStr}T120000`, + `TZID=/citadel.org/20210210_1/Europe/Oslo:${dateStr}T130000`, + 'Oslo Noon Event', 'tz-oslo-noon'), + ], osloVtimezone); + + cy.request({ + method: 'POST', + url: '/rest/e2e/addEvent', + body: { id: 'mock cal/tz-oslo-noon', ical: ics, calendar: 'mock cal' }, + }); + + cy.visit('/calendar'); + cy.get('.calendarListItem').should('have.length', 1); + cy.get('button.calendarMonthDayEvent').should('contain', 'Oslo Noon Event'); + + const expected = expectedDisplayTime(dateStr, 10, 0); + cy.get('button.calendarMonthDayEvent') + .contains('Oslo Noon Event') + .invoke('text') + .should('match', new RegExp(expected)); + }); + + it('should display all-day event on correct day in month view', () => { + const dateStr = futureDateStr(3); + const nextDay = futureDateStr(4); + + const ics = buildIcs([ + makeVevent(`VALUE=DATE:${dateStr}`, `VALUE=DATE:${nextDay}`, 'All-Day Event', 'tz-allday'), + ]); + + cy.request({ + method: 'POST', + url: '/rest/e2e/addEvent', + body: { id: 'mock cal/tz-allday', ical: ics, calendar: 'mock cal' }, + }); + + cy.visit('/calendar'); + cy.get('.calendarListItem').should('have.length', 1); + cy.get('button.calendarMonthDayEvent').should('contain', 'All-Day Event'); + + const targetDay = parseInt(dateStr.substring(6, 8), 10); + // Find the event first, then verify its parent cell's day number. + // Avoids ambiguity when the month view shows two cells with the same day number + // (e.g. April 3 and May 3 overflow in the April view). + cy.get('button.calendarMonthDayEvent') + .contains('All-Day Event') + .parents('.cal-cell-top') + .find('.cal-day-number') + .should(dayEl => { + expect(parseInt(dayEl.text().trim(), 10)).to.equal(targetDay); + }); + }); + + it('should display multi-day all-day event starting on correct day', () => { + const day1 = futureDateStr(3); + const day4 = futureDateStr(6); + + const ics = buildIcs([ + makeVevent(`VALUE=DATE:${day1}`, `VALUE=DATE:${day4}`, 'Multi-Day Event', 'tz-multiday'), + ]); + + cy.request({ + method: 'POST', + url: '/rest/e2e/addEvent', + body: { id: 'mock cal/tz-multiday', ical: ics, calendar: 'mock cal' }, + }); + + cy.visit('/calendar'); + cy.get('.calendarListItem').should('have.length', 1); + cy.get('button.calendarMonthDayEvent').should('contain', 'Multi-Day Event'); + + const startDay = parseInt(day1.substring(6, 8), 10); + // Find the event first, then verify its parent cell's day number. + cy.get('button.calendarMonthDayEvent') + .contains('Multi-Day Event') + .parents('.cal-cell-top') + .find('.cal-day-number') + .should(dayEl => { + expect(parseInt(dayEl.text().trim(), 10)).to.equal(startDay); + }); + }); }); diff --git a/e2e/cypress/integration/calendar-tz-bugs.ts b/e2e/cypress/integration/calendar-tz-bugs.ts deleted file mode 100644 index 0a69dbad7..000000000 --- a/e2e/cypress/integration/calendar-tz-bugs.ts +++ /dev/null @@ -1,298 +0,0 @@ -/// -// --------- BEGIN RUNBOX LICENSE --------- -// Copyright (C) 2016-2026 Runbox Solutions AS (runbox.com). -// -// This file is part of Runbox 7. -// -// Runbox 7 is free software: You can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the -// Free Software Foundation, either version 3 of the License, or (at your -// option) any later version. -// -// Runbox 7 is distributed in the hope that it will be useful, but -// WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -// General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with Runbox 7. If not, see . -// ---------- END RUNBOX LICENSE ---------- - -// Reproduction tests for staging feedback on PR #1779 - -import { futureDateStr, buildIcs, makeVevent, osloVtimezone, londonVtimezone, expectedDisplayTime } from '../support/ics-helpers'; - -/** - * Bug 1 (staging feedback): Creating an event with account tz = UK (London), - * local browser tz = CET (Oslo), event time 12:00 shows as 13:00 when editing. - * - * The root cause: EventEditorDialogComponent uses bare Date objects for - * event_start/event_end. The month view template uses event.start.getHours() - * which returns browser-local time, not account timezone time. - * - * We simulate a non-London browser by overriding Date.prototype.getHours - * to shift by +1h (simulating CEST = UTC+2 when account is BST = UTC+1). - * - * The event is at 12:00 London (BST = UTC+1 in summer) = 11:00 UTC. - * In account timezone London, it should display as 12:00. - * But event.start.getHours() returns browser-local time, which for a CET - * browser is 13:00 (11:00 UTC + 2h CEST). - */ - -// Simulates a browser in a different timezone by shifting getHours(). -// offsetHours: how many hours to add to the real getHours() result. -function mockBrowserTimezone(offsetHours: number) { - cy.visit('/calendar', { - onBeforeLoad(win) { - const origGetHours = win.Date.prototype.getHours; - win.Date.prototype.getHours = function () { - return (origGetHours.call(this) + offsetHours) % 24; - }; - }, - }); -} - -describe('Calendar timezone bug: event time offset when account tz differs', () => { - beforeEach(() => { - cy.request('/rest/e2e/resetCalendarEvents'); - // Set account timezone to London (differs from simulated CET/Oslo browser) - cy.request('/rest/e2e/setTimezone_Europe/London'); - }); - - afterEach(() => { - cy.request('/rest/e2e/resetTimezone'); - }); - - it('should display London TZID event at correct hour for London account', () => { - const dateStr = futureDateStr(3); - - // Event at 12:00 London (BST = UTC+1 in summer) = 11:00 UTC. - // With account tz = London, the template should display 12:00 via the - // Angular date pipe (which formats the Date in browser-local time). - const ics = buildIcs([ - makeVevent( - `TZID=Europe/London:${dateStr}T120000`, - `TZID=Europe/London:${dateStr}T130000`, - 'London Noon Meeting', 'bug1-london-001'), - ], londonVtimezone); - - // Pre-load the event into the mock server - cy.request({ - method: 'POST', - url: '/rest/e2e/addEvent', - body: { - id: 'mock cal/bug1-london-001', - ical: ics, - calendar: 'mock cal', - }, - }); - - cy.visit('/calendar'); - cy.get('.calendarListItem').should('have.length', 1); - - cy.get('button.calendarMonthDayEvent') - .should('contain', 'London Noon Meeting'); - - // The date pipe formats the Date (11:00 UTC) in browser-local time. - // Compute expected display time dynamically for any browser timezone. - // 12:00 London BST (UTC+1) = 11:00 UTC - const expectedLondon = expectedDisplayTime(dateStr, 11, 0); - cy.get('button.calendarMonthDayEvent') - .contains('London Noon Meeting') - .invoke('text') - .should('match', new RegExp(expectedLondon)); - }); - - it('should display floating time event at correct hour matching account tz', () => { - const dateStr = futureDateStr(3); - - // Floating time event at 14:00 (no TZID). - // With account tz = London, floating time is interpreted as London time. - // 14:00 London (BST = UTC+1) = 13:00 UTC - const ics = buildIcs([ - makeVevent( - `${dateStr}T140000`, - `${dateStr}T150000`, - 'Floating 2pm Meeting', 'bug1-floating-001'), - ]); - - cy.request({ - method: 'POST', - url: '/rest/e2e/addEvent', - body: { - id: 'mock cal/bug1-floating-001', - ical: ics, - calendar: 'mock cal', - }, - }); - - cy.visit('/calendar'); - cy.get('.calendarListItem').should('have.length', 1); - - cy.get('button.calendarMonthDayEvent') - .should('contain', 'Floating 2pm Meeting'); - - // Floating 14:00 interpreted as London BST = 13:00 UTC. - // Compute expected display time dynamically for any browser timezone. - const expectedFloating = expectedDisplayTime(dateStr, 13, 0); - cy.get('button.calendarMonthDayEvent') - .contains('Floating 2pm Meeting') - .invoke('text') - .should('match', new RegExp(expectedFloating)); - }); -}); - -/** - * Bug 3 (staging feedback): Changing account timezone doesn't update displayed - * event times, even after reloading the app. - * - * Root cause: CalendarService stores RunboxCalendarEvent instances with timezone - * set at construction time. When the user changes their account timezone in - * Personal Details, the existing event instances keep the old timezone. - * CalendarService doesn't re-generate events when the timezone changes. - */ -describe('Calendar timezone bug: events do not update after timezone change', () => { - beforeEach(() => { - cy.request('/rest/e2e/resetCalendarEvents'); - cy.request('/rest/e2e/resetTimezone'); - }); - - afterEach(() => { - cy.request('/rest/e2e/resetTimezone'); - }); - - it('should update displayed times when account timezone changes', () => { - const dateStr = futureDateStr(3); - - // Event at 14:00 Oslo time (CEST = UTC+2 in summer) = 12:00 UTC - const ics = buildIcs([ - makeVevent( - `TZID=/citadel.org/20210210_1/Europe/Oslo:${dateStr}T140000`, - `TZID=/citadel.org/20210210_1/Europe/Oslo:${dateStr}T150000`, - 'Oslo 2pm Meeting', 'bug3-oslo-001'), - ], osloVtimezone); - - cy.request({ - method: 'POST', - url: '/rest/e2e/addEvent', - body: { - id: 'mock cal/bug3-oslo-001', - ical: ics, - calendar: 'mock cal', - }, - }); - - // Load calendar with Oslo timezone, simulating CEST browser - cy.visit('/calendar', { - onBeforeLoad(win) { - // Simulate CEST browser to match Oslo account tz - const origGetHours = win.Date.prototype.getHours; - win.Date.prototype.getHours = function () { - return (origGetHours.call(this) + 1) % 24; - }; - }, - }); - cy.get('.calendarListItem').should('have.length', 1); - cy.get('button.calendarMonthDayEvent') - .should('contain', 'Oslo 2pm Meeting'); - - // Capture the displayed time with Oslo account tz - cy.get('button.calendarMonthDayEvent') - .contains('Oslo 2pm Meeting') - .invoke('text') - .then(osloText => { - // Now change account timezone to London - cy.request('/rest/e2e/setTimezone_Europe/London'); - - // Reload with same CEST browser mock — account tz now differs - cy.visit('/calendar', { - onBeforeLoad(win) { - const origGetHours = win.Date.prototype.getHours; - win.Date.prototype.getHours = function () { - return (origGetHours.call(this) + 1) % 24; - }; - }, - }); - cy.get('.calendarListItem').should('have.length', 1); - cy.get('button.calendarMonthDayEvent') - .should('contain', 'Oslo 2pm Meeting'); - - // With London timezone, the event should display differently: - // 14:00 CEST = 12:00 UTC = 13:00 BST (London) - // The displayed hour SHOULD change from Oslo display to London display. - cy.get('button.calendarMonthDayEvent') - .contains('Oslo 2pm Meeting') - .invoke('text') - .should(not => { - // The displayed time should have changed from the Oslo display. - }); - }); - }); - - it('should show different displayed hour after timezone change', () => { - const dateStr = futureDateStr(3); - - // Floating time 12:00. With Oslo account, interpreted as 12:00 CEST = 10:00 UTC - // With London account, interpreted as 12:00 BST = 11:00 UTC - const ics = buildIcs([ - makeVevent( - `${dateStr}T120000`, - `${dateStr}T130000`, - 'Floating Noon', 'bug3-floating-001'), - ]); - - // Load with Oslo timezone first, simulating CEST browser - cy.request({ - method: 'POST', - url: '/rest/e2e/addEvent', - body: { - id: 'mock cal/bug3-floating-001', - ical: ics, - calendar: 'mock cal', - }, - }); - - cy.visit('/calendar', { - onBeforeLoad(win) { - const origGetHours = win.Date.prototype.getHours; - win.Date.prototype.getHours = function () { - return (origGetHours.call(this) + 1) % 24; - }; - }, - }); - cy.get('.calendarListItem').should('have.length', 1); - - // Get displayed hour with Oslo tz + CEST browser mock - cy.get('button.calendarMonthDayEvent') - .contains('Floating Noon') - .invoke('text') - .then(osloText => { - const osloHour = osloText.match(/(\d+):\d+/); - - // Switch to London, keep same CEST browser mock - cy.request('/rest/e2e/setTimezone_Europe/London'); - cy.visit('/calendar', { - onBeforeLoad(win) { - const origGetHours = win.Date.prototype.getHours; - win.Date.prototype.getHours = function () { - return (origGetHours.call(this) + 1) % 24; - }; - }, - }); - cy.get('.calendarListItem').should('have.length', 1); - - cy.get('button.calendarMonthDayEvent') - .contains('Floating Noon') - .invoke('text') - .then(londonText => { - const londonHour = londonText.match(/(\d+):\d+/); - // After timezone change, the event should be re-interpreted. - // Bug: Currently the hours will be THE SAME because - // CalendarService doesn't regenerate events on tz change. - // After fix: they should differ. - expect(londonHour?.[1]).to.not.equal(osloHour?.[1], - 'Displayed hour should change after timezone change'); - }); - }); - }); -}); diff --git a/src/app/calendar-app/runbox-calendar-event.spec.ts b/src/app/calendar-app/runbox-calendar-event.spec.ts index 52b47de49..f73a511c3 100644 --- a/src/app/calendar-app/runbox-calendar-event.spec.ts +++ b/src/app/calendar-app/runbox-calendar-event.spec.ts @@ -61,6 +61,45 @@ describe('RunboxCalendarEvent', () => { now.isDate = true; expect(newEvent.toIcal()).toContain(now.toICALString()); }); + // All-day event display correctness with positive UTC offsets + // Bug: toJSDate() shifts midnight to previous day UTC (e.g. midnight CEST = 22:00 UTC prev day) + it('should display all-day event on correct day with positive UTC offset', () => { + ensureTimezone('Europe/Oslo', 1, 2); + + const event = RunboxCalendarEvent.newEmpty('Europe/Oslo'); + const startMoment = moment('2026-04-29T00:00:00').seconds(0).milliseconds(0); + const endMoment = moment('2026-04-30T00:00:00').seconds(0).milliseconds(0); + + event.updateEvent( + startMoment, endMoment, true, 'test-cal', + RecurSaveType.ALL_OCCURENCES, 'All-day event', '', '', + false, '', 0, [], [], [] + ); + + expect(event.allDay).toBe(true, 'event should be all-day'); + expect(event.start.getDate()).toBe(29, + 'All-day event on April 29 should have start.getDate() = 29'); + expect(event.start.getMonth()).toBe(3, + 'All-day event on April 29 should have start.getMonth() = 3 (April)'); + }); + it('should display multi-day all-day event on correct start and end days', () => { + ensureTimezone('Europe/Oslo', 1, 2); + + const event = RunboxCalendarEvent.newEmpty('Europe/Oslo'); + const startMoment = moment('2026-04-29T00:00:00').seconds(0).milliseconds(0); + const endMoment = moment('2026-05-01T00:00:00').seconds(0).milliseconds(0); + + event.updateEvent( + startMoment, endMoment, true, 'test-cal', + RecurSaveType.ALL_OCCURENCES, 'Multi-day event', '', '', + false, '', 0, [], [], [] + ); + + expect(event.start.getDate()).toBe(29, + 'Multi-day event start should be April 29'); + expect(event.end.getDate()).toBe(30, + 'Multi-day event end should be April 30'); + }); it('should be possible to add/edit/remove a WEEKLY recurrence rule', () => { const sut = new RunboxCalendarEvent( 'testcal/testev', new ICAL.Event(new ICAL.Component(['vcalendar', [], [ @@ -615,6 +654,90 @@ ICAL.TimezoneService.register(tz.tzid, tz); expect(eventSimple.start.getUTCHours()).toBe(16, 'Simple tz name should also work'); }); + it('should display all-day event on correct day with citadel-path timezone', () => { + // Bug: All-day events with citadel-path TZID display on previous day + // because toJSDate() shifts midnight CEST to 22:00 UTC previous day + ensureTimezone('/citadel.org/20210210_1/Europe/Oslo', 1, 2); + + const icalData = `BEGIN:VCALENDAR +BEGIN:VTIMEZONE +TZID:/citadel.org/20210210_1/Europe/Oslo +BEGIN:STANDARD +DTSTART:19701025T020000 +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19810329T010000 +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VEVENT +UID:allday-citadel-test +DTSTART;VALUE=DATE:20260429 +DTEND;VALUE=DATE:20260430 +SUMMARY:Citadel All-Day Event +END:VEVENT +END:VCALENDAR`; + + const jcal = ICAL.parse(icalData); + const ical = new ICAL.Component(jcal); + + for (const tzComponent of ical.getAllSubcomponents('vtimezone')) { + const tz = new ICAL.Timezone({ + tzid: tzComponent.getFirstPropertyValue('tzid'), + component: tzComponent, + }); + if (!ICAL.TimezoneService.has(tz.tzid)) { + ICAL.TimezoneService.register(tz.tzid, tz); + } + } + + const vevent = ical.getFirstSubcomponent('vevent'); + const event = new RunboxCalendarEvent( + 'testcal/allday-citadel', + new ICAL.Event(vevent), + ICAL.Time.fromDateString('2026-04-29'), + ICAL.Time.fromDateString('2026-04-30'), + '/citadel.org/20210210_1/Europe/Oslo' + ); + + expect(event.allDay).toBe(true, 'event should be all-day'); + expect(event.start.getDate()).toBe(29, + 'All-day event on April 29 should display on day 29 even with citadel-path TZ'); + }); + + it('should preserve time when event timezone is not registered', () => { + // Bug: When event TZID is not in TimezoneService, time shifts incorrectly + ensureTimezone('Europe/Berlin', 1, 2); + // Europe/Oslo is NOT registered + + const vevent = new ICAL.Component(['vevent', [ + ['dtstart', { tzid: 'Europe/Oslo' }, 'date-time', '2026-04-29T12:00:00'], + ['dtend', { tzid: 'Europe/Oslo' }, 'date-time', '2026-04-29T13:00:00'], + ['summary', {}, 'text', 'Oslo Noon (unresolved TZ)'], + ]]); + const dtstartProp = vevent.getFirstProperty('dtstart'); + const dtstart = ICAL.Time.fromDateTimeString('2026-04-29T12:00:00', dtstartProp); + const dtend = ICAL.Time.fromDateTimeString('2026-04-29T13:00:00', dtstartProp); + + const event = new RunboxCalendarEvent( + 'testcal/unresolved-tz', + new ICAL.Event(vevent), + dtstart, + dtend, + 'Europe/Berlin' + ); + + // When TZID is unresolvable, fallback should preserve the local time as UTC + // 12:00 Oslo (unresolved) → 12:00 UTC (fallback, not 10:00 UTC which would be correct) + expect(event.start.getUTCHours()).toBe(12, + 'Unresolved TZ should preserve local time as UTC'); + }); + // Reproduction tests for staging feedback (PR #1779) it('should round-trip entered time correctly when creating event via updateEvent', () => { @@ -644,6 +767,27 @@ ICAL.TimezoneService.register(tz.tzid, tz); 'start Date should be 11:00 UTC (12:00 BST)'); }); + it('should round-trip 12pm timed event with Oslo/citadel-path timezone', () => { + // Production TZID is a citadel path — moment-timezone can't resolve it + ensureTimezone('/citadel.org/20210210_1/Europe/Oslo', 1, 2); + + const event = RunboxCalendarEvent.newEmpty('/citadel.org/20210210_1/Europe/Oslo'); + const startMoment = moment('2026-04-29T12:00:00').seconds(0).milliseconds(0); + const endMoment = startMoment.clone().add(1, 'hour'); + + event.updateEvent( + startMoment, endMoment, false, 'test-cal', + RecurSaveType.ALL_OCCURENCES, 'Oslo Noon Event', '', '', + false, '', 0, [], [], [] + ); + + // April 29 = CEST (UTC+2), so 12:00 CEST = 10:00 UTC + expect(event.dtstart.hour()).toBe(12, + 'dtstart should show 12:00 in account timezone (Oslo)'); + expect(event.start.toISOString()).toBe('2026-04-29T10:00:00.000Z', + 'start Date should be 10:00 UTC (12:00 CEST)'); + }); + it('should store exception at user-entered time when event tz differs from account tz', () => { // Bug 2 (staging feedback): Recurring event at 09:00 Berlin, account tz = London. // User edits one occurrence from 09:00 to 10:00 (shown in browser local time). diff --git a/src/app/calendar-app/runbox-calendar-event.ts b/src/app/calendar-app/runbox-calendar-event.ts index 8280152c1..00ca37388 100644 --- a/src/app/calendar-app/runbox-calendar-event.ts +++ b/src/app/calendar-app/runbox-calendar-event.ts @@ -168,6 +168,12 @@ export class RunboxCalendarEvent implements CalendarEvent { * 3. Unresolved TZID (has TZID but not found) → preserve local time values as UTC */ private convertIcalTimeToDate(time: ICAL.Time, propName: string): Date { + if (time.isDate) { + // All-day dates must display on the same calendar date regardless of timezone. + // Use noon UTC so getDate() returns the correct day in every timezone. + return new Date(Date.UTC(time.year, time.month - 1, time.day, 12, 0, 0)); + } + const zone = time.zone; // Check for proper timezone with VTIMEZONE data or UTC const hasProperTimezone = zone && @@ -666,12 +672,29 @@ export class RunboxCalendarEvent implements CalendarEvent { } else { // Assemble a moment with the UTC Date() and the user's timezone let my_timezone: string | null = time.zone && time.zone.component ? time.zone.component.getFirstPropertyValue('x-lic-location') as string : null; - my_timezone = my_timezone || this.timezone || moment.tz.guess(); + my_timezone = my_timezone && moment.tz.zone(my_timezone) + ? my_timezone + : this.resolveTimezoneName(); const m = moment(time.toJSDate()).tz(my_timezone); return m; } } + /** Resolve a usable IANA timezone name for moment-timezone. */ + private resolveTimezoneName(): string { + if (this.timezone && moment.tz.zone(this.timezone)) { + return this.timezone; + } + // Extract IANA from path-style TZIDs: /citadel.org/.../Europe/Oslo → Europe/Oslo + if (this.timezone && this.timezone.includes('/')) { + const iana = this.timezone.split('/').slice(-2).join('/'); + if (moment.tz.zone(iana)) { + return iana; + } + } + return moment.tz.guess(); + } + /** Safely get the RRULE as an ICAL.Recur, or null if missing/not a Recur instance. */ private getRecur(): ICAL.Recur | null { const recur = this.event.component.getFirstPropertyValue('rrule'); @@ -684,6 +707,22 @@ export class RunboxCalendarEvent implements CalendarEvent { } private momentToIcalTime(input: moment.Moment, zone: ICAL.Timezone | null | undefined): ICAL.Time { + if (this._allDay) { + // All-day events: use local date parts to preserve the calendar date + // regardless of the UTC offset. Midnight local May 1 must store day=1, + // not day=30 (which would result from extracting UTC date parts). + const d = input.toDate(); + const ical_time = ICAL.Time.fromJSDate(d); + ical_time.isDate = true; + const accountTz = this.getAccountTimezone(); + if (accountTz) { + ical_time.zone = accountTz; + if (!this.ical.getFirstSubcomponent('vtimezone')) { + this.ical.addSubcomponent(accountTz.component); + } + } + return ical_time; + } // No supplied tz = new, or original didnt have one: // (Is it legit to have dates with tzs and without in same ical?) if (!zone || zone.tzid === 'floating') { From 6f4c89054c9d13ab1d32a41238e359650a42a96e Mon Sep 17 00:00:00 2001 From: Finn Date: Fri, 1 May 2026 06:12:40 +0100 Subject: [PATCH 13/42] refactor(calendar): tighten types and remove dead null checks in event timezone code --- src/app/calendar-app/runbox-calendar-event.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/app/calendar-app/runbox-calendar-event.ts b/src/app/calendar-app/runbox-calendar-event.ts index 00ca37388..25f49519d 100644 --- a/src/app/calendar-app/runbox-calendar-event.ts +++ b/src/app/calendar-app/runbox-calendar-event.ts @@ -167,7 +167,7 @@ export class RunboxCalendarEvent implements CalendarEvent { * 2. True floating time (no TZID in property) → interpret in calendar's timezone * 3. Unresolved TZID (has TZID but not found) → preserve local time values as UTC */ - private convertIcalTimeToDate(time: ICAL.Time, propName: string): Date { + private convertIcalTimeToDate(time: ICAL.Time, propName: 'dtstart' | 'dtend'): Date { if (time.isDate) { // All-day dates must display on the same calendar date regardless of timezone. // Use noon UTC so getDate() returns the correct day in every timezone. @@ -184,10 +184,8 @@ export class RunboxCalendarEvent implements CalendarEvent { if (hasProperTimezone) { const targetTz = this.getAccountTimezone(); - if (targetTz) { - time = time.convertToZone(targetTz); - } - return time.toJSDate(); + const converted = targetTz ? time.convertToZone(targetTz) : time; + return converted.toJSDate(); } // Check if the property had a TZID parameter to differentiate floating vs unresolved @@ -330,10 +328,10 @@ export class RunboxCalendarEvent implements CalendarEvent { if (recur) { recur.until = null; recur.count = null; - if (typeof end === 'number' && end != null) { + if (typeof end === 'number') { recur.count = end; } - if (end instanceof Date && end != null) { + if (end instanceof Date) { // Must be a date (cant do typeof === 'Date' !? const zone = this.event.startDate.zone; const icaltime = ICAL.Time.fromJSDate(end); From 200b091721c91510521043f28e0a58013758350b Mon Sep 17 00:00:00 2001 From: Finn Date: Tue, 5 May 2026 14:25:33 +0100 Subject: [PATCH 14/42] fix(calendar): harden timezone tests and simplify all-day end getter - Add navigateToEventMonth to prevent E2E flakiness at month boundaries - Fix ensureTimezone helper indentation in spec file - Add cross-reference comment on all-day end getter duplication - Replace undefined with typed values in test calls - Add negative-offset timezone unit test (America/New_York) - Add dateStrDay helper for consistent date string parsing - Override CSS visibility for add-event button in E2E dialog test - Use input[matInput] selector for dialog title field --- e2e/cypress/integration/calendar-timezone.ts | 73 ++++++++++++++++++- e2e/cypress/support/ics-helpers.ts | 12 +++ .../runbox-calendar-event.spec.ts | 64 +++++++++++++--- src/app/calendar-app/runbox-calendar-event.ts | 16 ++-- 4 files changed, 147 insertions(+), 18 deletions(-) diff --git a/e2e/cypress/integration/calendar-timezone.ts b/e2e/cypress/integration/calendar-timezone.ts index 720cdd201..218796c0f 100644 --- a/e2e/cypress/integration/calendar-timezone.ts +++ b/e2e/cypress/integration/calendar-timezone.ts @@ -18,7 +18,7 @@ // along with Runbox 7. If not, see . // ---------- END RUNBOX LICENSE ---------- -import { futureDateStr, buildIcs, makeVevent, osloVtimezone, londonVtimezone, expectedDisplayTime } from '../support/ics-helpers'; +import { futureDateStr, buildIcs, makeVevent, osloVtimezone, londonVtimezone, expectedDisplayTime, dateStrMonth, dateStrYear, dateStrDay } from '../support/ics-helpers'; describe('Calendar timezone handling', () => { beforeEach(() => { @@ -42,6 +42,27 @@ describe('Calendar timezone handling', () => { cy.get('simple-snack-bar').should('contain', 'events imported'); } + // Navigate the calendar to the month containing the event so that + // month-view assertions work regardless of when the test runs. + function navigateToEventMonth(dateStr: string) { + const targetMonth = dateStrMonth(dateStr); + const targetYear = dateStrYear(dateStr); + cy.window().then(win => { + const now = new Date(); + const currentMonth = now.getMonth() + 1; + const currentYear = now.getFullYear(); + const monthsAhead = (targetYear - currentYear) * 12 + (targetMonth - currentMonth); + const buttonId = monthsAhead >= 0 ? '#nextPeriodButton' : '#previousPeriodButton'; + const clicks = Math.abs(monthsAhead); + for (let i = 0; i < clicks; i++) { + cy.get(buttonId).click(); + } + if (clicks > 0) { + cy.wait(500); + } + }); + } + it('should display floating time event in import preview', () => { const dateStr = futureDateStr(15); selectIcs(buildIcs([ @@ -161,6 +182,7 @@ describe('Calendar timezone handling', () => { cy.visit('/calendar'); cy.get('.calendarListItem').should('have.length', 1); + navigateToEventMonth(dateStr); cy.get('button.calendarMonthDayEvent').should('contain', 'London Noon Meeting'); const expected = expectedDisplayTime(dateStr, 11, 0); @@ -189,6 +211,7 @@ describe('Calendar timezone handling', () => { cy.visit('/calendar'); cy.get('.calendarListItem').should('have.length', 1); + navigateToEventMonth(dateStr); cy.get('button.calendarMonthDayEvent').should('contain', 'Floating 2pm Meeting'); const expected = expectedDisplayTime(dateStr, 13, 0); @@ -219,6 +242,7 @@ describe('Calendar timezone handling', () => { // View with default Oslo timezone cy.visit('/calendar'); cy.get('.calendarListItem').should('have.length', 1); + navigateToEventMonth(dateStr); cy.get('button.calendarMonthDayEvent') .contains('Floating Noon') @@ -230,6 +254,7 @@ describe('Calendar timezone handling', () => { cy.request('/rest/e2e/setTimezone_Europe/London'); cy.visit('/calendar'); cy.get('.calendarListItem').should('have.length', 1); + navigateToEventMonth(dateStr); cy.get('button.calendarMonthDayEvent') .contains('Floating Noon') @@ -263,6 +288,7 @@ describe('Calendar timezone handling', () => { cy.visit('/calendar'); cy.get('.calendarListItem').should('have.length', 1); + navigateToEventMonth(dateStr); cy.get('button.calendarMonthDayEvent').should('contain', 'Oslo Noon Event'); const expected = expectedDisplayTime(dateStr, 10, 0); @@ -288,9 +314,10 @@ describe('Calendar timezone handling', () => { cy.visit('/calendar'); cy.get('.calendarListItem').should('have.length', 1); + navigateToEventMonth(dateStr); cy.get('button.calendarMonthDayEvent').should('contain', 'All-Day Event'); - const targetDay = parseInt(dateStr.substring(6, 8), 10); + const targetDay = dateStrDay(dateStr); // Find the event first, then verify its parent cell's day number. // Avoids ambiguity when the month view shows two cells with the same day number // (e.g. April 3 and May 3 overflow in the April view). @@ -303,6 +330,45 @@ describe('Calendar timezone handling', () => { }); }); + it('should create all-day event via dialog on correct day', () => { + // Switch to month view and navigate to next month for a clean view + cy.contains('button.calendarToolbarButton', 'Month').click(); + cy.get('#nextPeriodButton').click(); + cy.wait(500); + + // Pick the 15th of next month — find its add-event button + cy.get('.cal-cell-top').then(cells => { + const targetDay = 15; + let targetCell: JQuery = null; + cells.each((i, el) => { + const dayNum = parseInt(Cypress.$(el).find('.cal-day-number').text().trim(), 10); + // First occurrence of targetDay in the grid belongs to the displayed month + if (dayNum === targetDay && !targetCell) { + targetCell = Cypress.$(el); + } + }); + cy.wrap(targetCell).find('.add-new-event').invoke('css', 'visibility', 'visible'); + cy.wrap(targetCell).find('.add-new-event button').should('be.visible').click(); + }); + + cy.get('mat-dialog-container').within(() => { + cy.get('input[matInput]').first().clear().type('Created All-Day Event'); + cy.get('mat-checkbox').contains('All-day event').click(); + cy.get('#eventSubmitButton').click(); + }); + + cy.get('button.calendarMonthDayEvent') + .should('contain', 'Created All-Day Event'); + + cy.get('button.calendarMonthDayEvent') + .contains('Created All-Day Event') + .parents('.cal-cell-top') + .find('.cal-day-number') + .should(dayEl => { + expect(parseInt(dayEl.text().trim(), 10)).to.equal(15); + }); + }); + it('should display multi-day all-day event starting on correct day', () => { const day1 = futureDateStr(3); const day4 = futureDateStr(6); @@ -319,9 +385,10 @@ describe('Calendar timezone handling', () => { cy.visit('/calendar'); cy.get('.calendarListItem').should('have.length', 1); + navigateToEventMonth(day1); cy.get('button.calendarMonthDayEvent').should('contain', 'Multi-Day Event'); - const startDay = parseInt(day1.substring(6, 8), 10); + const startDay = dateStrDay(day1); // Find the event first, then verify its parent cell's day number. cy.get('button.calendarMonthDayEvent') .contains('Multi-Day Event') diff --git a/e2e/cypress/support/ics-helpers.ts b/e2e/cypress/support/ics-helpers.ts index 54fe70e5b..d58ac4fea 100644 --- a/e2e/cypress/support/ics-helpers.ts +++ b/e2e/cypress/support/ics-helpers.ts @@ -23,6 +23,18 @@ export function futureDateStr(daysFromNow: number): string { return d.toISOString().replace(/-/g, '').replace(/T.*/, ''); } +export function dateStrMonth(dateStr: string): number { + return parseInt(dateStr.substring(4, 6), 10); +} + +export function dateStrYear(dateStr: string): number { + return parseInt(dateStr.substring(0, 4), 10); +} + +export function dateStrDay(dateStr: string): number { + return parseInt(dateStr.substring(6, 8), 10); +} + export function buildIcs(veventBlocks: string[], vtimezone?: string): string { const parts = [ 'BEGIN:VCALENDAR', diff --git a/src/app/calendar-app/runbox-calendar-event.spec.ts b/src/app/calendar-app/runbox-calendar-event.spec.ts index f73a511c3..3e0628f8f 100644 --- a/src/app/calendar-app/runbox-calendar-event.spec.ts +++ b/src/app/calendar-app/runbox-calendar-event.spec.ts @@ -100,6 +100,29 @@ describe('RunboxCalendarEvent', () => { expect(event.end.getDate()).toBe(30, 'Multi-day event end should be April 30'); }); + it('should display created all-day event start and end on correct day', () => { + ensureTimezone('Europe/Oslo', 1, 2); + + const event = RunboxCalendarEvent.newEmpty('Europe/Oslo'); + // Simulate dialog: start at noon, end at 2pm (+1 day added by dialog for exclusive DTEND) + const startMoment = moment('2026-05-05T12:00:00').seconds(0).milliseconds(0); + const endMoment = moment('2026-05-06T14:00:00').seconds(0).milliseconds(0); + + event.updateEvent( + startMoment, endMoment, true, 'test-cal', + RecurSaveType.ALL_OCCURENCES, 'All-day event', '', '', + false, '', 0, [], [], [] + ); + + expect(event.start.getDate()).toBe(5, + 'Created all-day event start day should be 5'); + expect(event.end.getDate()).toBe(5, + 'Created all-day event end day should be 5 (inclusive)'); + expect(event.start.getMonth()).toBe(4, + 'Created all-day event start month should be May (4)'); + expect(event.end.getMonth()).toBe(4, + 'Created all-day event end month should be May (4)'); + }); it('should be possible to add/edit/remove a WEEKLY recurrence rule', () => { const sut = new RunboxCalendarEvent( 'testcal/testev', new ICAL.Event(new ICAL.Component(['vcalendar', [], [ @@ -269,11 +292,11 @@ describe('RunboxCalendarEvent', () => { false, sut.calendar, RecurSaveType.THIS_ONLY, - 'Moved weekly event one hour', undefined, undefined, + 'Moved weekly event one hour', '', '', true, sut.recurringFrequency, sut.recurInterval, - undefined, undefined, undefined, // and optional params.. + [], [], [], ); expect(sut.toIcal()).toContain('SUMMARY:Moved weekly event one hour'); @@ -409,7 +432,7 @@ END:VCALENDAR` true, sut.recurringFrequency, sut.recurInterval, - undefined, undefined, undefined, // and optional params.. + [], [], [], ); expect(sut.toIcal()).toContain('SUMMARY:Moved daily event one hour'); @@ -527,10 +550,10 @@ TZOFFSETTO:+${dst}00 RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU END:DAYLIGHT END:VTIMEZONE`; -const comp = new ICAL.Component(ICAL.parse(tzData)); -const tz = new ICAL.Timezone({ tzid, component: comp }); -ICAL.TimezoneService.register(tz.tzid, tz); -} + const comp = new ICAL.Component(ICAL.parse(tzData)); + const tz = new ICAL.Timezone({ tzid, component: comp }); + ICAL.TimezoneService.register(tz.tzid, tz); + } it('should correctly convert London TZID event to Berlin user timezone', () => { // Simulates an issue: 4pm London event shown as 3pm (wrong) instead of 5pm (correct) @@ -867,9 +890,9 @@ END:VCALENDAR` sut.updateEvent( newStart, newEnd, false, sut.calendar, RecurSaveType.THIS_ONLY, - 'Moved to 10am', undefined, undefined, + 'Moved to 10am', '', '', true, sut.recurringFrequency, sut.recurInterval, - undefined, undefined, undefined + [], [], [] ); // The exception should exist in the ICAL data @@ -921,4 +944,27 @@ END:VCALENDAR` expect(event.start.getUTCHours()).toBe(16, 'start Date UTC time should not change (16:00 UTC)'); }); + + it('should display UTC event at correct hour for negative-offset account', () => { + // Exercises the via-UTC path in momentToIcalTime with a negative offset (America/New_York) + ensureTimezone('America/New_York', -5, -4); // EST / EDT + + const event = RunboxCalendarEvent.newEmpty('America/New_York'); + // July 15 = EDT (UTC-4), so 13:00 EDT = 17:00 UTC + const startMoment = moment('2026-07-15T13:00:00').seconds(0).milliseconds(0); + const endMoment = moment('2026-07-15T14:00:00').seconds(0).milliseconds(0); + + event.updateEvent( + startMoment, endMoment, false, 'test-cal', + RecurSaveType.ALL_OCCURENCES, 'NY Afternoon Event', '', '', + false, '', 0, [], [], [] + ); + + // 13:00 EDT (UTC-4) = 17:00 UTC + expect(event.start.toISOString()).toBe('2026-07-15T17:00:00.000Z', + '1pm New York EDT should be 17:00 UTC'); + // dtstart moment should show 13:00 in New York + expect(event.dtstart.hour()).toBe(13, + 'dtstart should show 13:00 in account timezone (New York)'); + }); }); diff --git a/src/app/calendar-app/runbox-calendar-event.ts b/src/app/calendar-app/runbox-calendar-event.ts index 25f49519d..43009785d 100644 --- a/src/app/calendar-app/runbox-calendar-event.ts +++ b/src/app/calendar-app/runbox-calendar-event.ts @@ -147,15 +147,19 @@ export class RunboxCalendarEvent implements CalendarEvent { if (!this._dtend) { return undefined; } - - const shownEnd = this._dtend.clone(); // ICAL event DTEND is exclusive, angular-calendar is inclusive if (this.allDay) { - shownEnd.addDuration(new ICAL.Duration({'isNegative': true, 'days': 1})); - } else { - shownEnd.addDuration(new ICAL.Duration({'isNegative': true, 'seconds': 1})); + // Subtract 1 day at the Date level to avoid ICAL.js clone/normalize subtleties. + // Uses same noon-UTC pattern as convertIcalTimeToDate — keep in sync. + return new Date(Date.UTC( + this._dtend.year, + this._dtend.month - 1, + this._dtend.day - 1, + 12, 0, 0 + )); } - + const shownEnd = this._dtend.clone(); + shownEnd.addDuration(new ICAL.Duration({'isNegative': true, 'seconds': 1})); return this.convertIcalTimeToDate(shownEnd, 'dtend'); } From 69163f607e879a1630c8cb5bed8f246979bb162d Mon Sep 17 00:00:00 2001 From: Finn Date: Tue, 5 May 2026 14:48:46 +0100 Subject: [PATCH 15/42] refactor(calendar): clean up timezone test helpers and fix offset formatting - Remove unused win parameter from navigateToEventMonth - Reuse dateStrYear/Month/Day in expectedDisplayTime - Fix ensureTimezone negative offset formatting (-0500 not +-500) - Remove redundant narrating comments in E2E tests --- e2e/cypress/integration/calendar-timezone.ts | 4 +--- e2e/cypress/support/ics-helpers.ts | 5 +---- src/app/calendar-app/runbox-calendar-event.spec.ts | 13 +++++++------ 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/e2e/cypress/integration/calendar-timezone.ts b/e2e/cypress/integration/calendar-timezone.ts index 218796c0f..0eeae43ca 100644 --- a/e2e/cypress/integration/calendar-timezone.ts +++ b/e2e/cypress/integration/calendar-timezone.ts @@ -47,7 +47,7 @@ describe('Calendar timezone handling', () => { function navigateToEventMonth(dateStr: string) { const targetMonth = dateStrMonth(dateStr); const targetYear = dateStrYear(dateStr); - cy.window().then(win => { + cy.then(() => { const now = new Date(); const currentMonth = now.getMonth() + 1; const currentYear = now.getFullYear(); @@ -318,9 +318,7 @@ describe('Calendar timezone handling', () => { cy.get('button.calendarMonthDayEvent').should('contain', 'All-Day Event'); const targetDay = dateStrDay(dateStr); - // Find the event first, then verify its parent cell's day number. // Avoids ambiguity when the month view shows two cells with the same day number - // (e.g. April 3 and May 3 overflow in the April view). cy.get('button.calendarMonthDayEvent') .contains('All-Day Event') .parents('.cal-cell-top') diff --git a/e2e/cypress/support/ics-helpers.ts b/e2e/cypress/support/ics-helpers.ts index d58ac4fea..ea0c60953 100644 --- a/e2e/cypress/support/ics-helpers.ts +++ b/e2e/cypress/support/ics-helpers.ts @@ -91,10 +91,7 @@ export const osloVtimezone = [ * dateStr: 'YYYYMMDD' format date string. */ export function expectedDisplayTime(dateStr: string, utcHour: number, utcMinute: number = 0): string { - const y = parseInt(dateStr.substring(0, 4)); - const m = parseInt(dateStr.substring(4, 6)) - 1; - const d = parseInt(dateStr.substring(6, 8)); - const date = new Date(Date.UTC(y, m, d, utcHour, utcMinute, 0)); + const date = new Date(Date.UTC(dateStrYear(dateStr), dateStrMonth(dateStr) - 1, dateStrDay(dateStr), utcHour, utcMinute, 0)); return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; } diff --git a/src/app/calendar-app/runbox-calendar-event.spec.ts b/src/app/calendar-app/runbox-calendar-event.spec.ts index 3e0628f8f..8dde2b4fa 100644 --- a/src/app/calendar-app/runbox-calendar-event.spec.ts +++ b/src/app/calendar-app/runbox-calendar-event.spec.ts @@ -533,20 +533,21 @@ END:VTIMEZONE`; // Helper to create and register a timezone from standard offsets function ensureTimezone(tzid: string, stdOffset: number, dstOffset: number) { if (ICAL.TimezoneService.has(tzid)) { return; } - const std = String(stdOffset).padStart(2, '0'); - const dst = String(dstOffset).padStart(2, '0'); + const fmt = (n: number) => (n >= 0 ? '+' : '-') + String(Math.abs(n)).padStart(2, '0'); + const std = fmt(stdOffset); + const dst = fmt(dstOffset); const tzData = `BEGIN:VTIMEZONE TZID:${tzid} BEGIN:STANDARD DTSTART:19701025T020000 -TZOFFSETFROM:+${dst}00 -TZOFFSETTO:+${std}00 +TZOFFSETFROM:${dst}00 +TZOFFSETTO:${std}00 RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU END:STANDARD BEGIN:DAYLIGHT DTSTART:19810329T010000 -TZOFFSETFROM:+${std}00 -TZOFFSETTO:+${dst}00 +TZOFFSETFROM:${std}00 +TZOFFSETTO:${dst}00 RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU END:DAYLIGHT END:VTIMEZONE`; From db714d8beb8474fba5f3697fa73c47439260feae Mon Sep 17 00:00:00 2001 From: Finn Date: Tue, 5 May 2026 18:37:57 +0100 Subject: [PATCH 16/42] fix(calendar): use calendar-prefixed event IDs in mockserver PUT handler The PUT handler assigned bare IDs like "mock-event-1" while the import handler uses "calendarId/eventId". When RunboxCalendarEvent re-parses a fetched event it extracts _calendar from id.split('/')[0], getting "mock-event-1" instead of "mock cal". This caused filterEvents() to drop the event because calendarVisibility["mock-event-1"] is undefined. --- e2e/mockserver/mockserver.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/e2e/mockserver/mockserver.ts b/e2e/mockserver/mockserver.ts index 9227c6907..422fd336b 100644 --- a/e2e/mockserver/mockserver.ts +++ b/e2e/mockserver/mockserver.ts @@ -1104,8 +1104,9 @@ END:VCALENDAR }); request.on('end', () => { const event = JSON.parse(body); - event['id'] = 'mock-event-' + (this.events.length + 1); + event['id'] = (event['calendar'] || 'mock cal') + '/mock-event-' + (this.events.length + 1); this.events.push(event); + this.bumpSyncToken(); response.end(JSON.stringify({ 'status': 'success', 'result': { From b5753aae8d538225365d13d278153b2635601114 Mon Sep 17 00:00:00 2001 From: Geir Thomas Andersen Date: Tue, 5 May 2026 21:30:26 +0200 Subject: [PATCH 17/42] fix(signup): Add missing license header. --- e2e/cypress/integration/signup.ts | 19 +++++++++++++++++++ src/app/signup/signup.component.html | 19 +++++++++++++++++++ src/app/signup/signup.component.scss | 19 +++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/e2e/cypress/integration/signup.ts b/e2e/cypress/integration/signup.ts index 793b28050..5a49db8b9 100644 --- a/e2e/cypress/integration/signup.ts +++ b/e2e/cypress/integration/signup.ts @@ -1,3 +1,22 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2026 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + /// describe('Signup', () => { diff --git a/src/app/signup/signup.component.html b/src/app/signup/signup.component.html index 1de38c2b2..9c8e29594 100644 --- a/src/app/signup/signup.component.html +++ b/src/app/signup/signup.component.html @@ -1,3 +1,22 @@ + +