Files
bewcloud/lib/utils/calendar_test.ts
Bruno Bernardino 15dcc8803d Basic CalDav UI (Calendar)
This implements a basic CalDav UI, titled "Calendar". It allows creating new calendars and events with a start and end date, URL, location, and description.

You can also import and export ICS (VCALENDAR + VEVENT) files.

It allows editing the ICS directly, for power users.

Additionally, you can hide/display events from certain calendars, change their names and their colors. If there's no calendar created yet in your CalDav server (first-time setup), it'll automatically create one, titled "Calendar".

You can also change the display timezone for the calendar from the settings.

Finally, there's some minor documentation fixes and some other minor tweaks.

Closes #56
Closes #89
2025-09-06 12:46:13 +01:00

1916 lines
45 KiB
TypeScript

import { assertEquals } from 'std/assert/assert_equals.ts';
import { assertMatch } from 'std/assert/assert_match.ts';
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
import {
convertRRuleToWords,
generateVCalendar,
generateVEvent,
getCalendarEventStyle,
getColorAsHex,
getDateRangeForCalendarView,
getDaysForWeek,
getIdFromVEvent,
getWeeksForMonth,
parseIcsDate,
parseVCalendar,
splitTextIntoVEvents,
updateIcs,
} from './calendar.ts';
Deno.test('that getColorAsHex works', () => {
const tests: { input: string; expected: string | undefined }[] = [
{ input: 'bg-red-700', expected: '#B51E1F' },
{ input: 'bg-green-700', expected: '#148041' },
{ input: 'bg-blue-900', expected: '#1E3A89' },
{ input: 'bg-purple-800', expected: '#6923A9' },
{ input: 'bg-gray-700', expected: '#384354' },
{ input: 'invalid-color', expected: '#384354' },
];
for (const test of tests) {
const output = getColorAsHex(test.input);
assertEquals(output, test.expected);
}
});
Deno.test('that getIdFromVEvent works', () => {
const tests: { input: string; expected?: string; shouldBeUUID?: boolean }[] = [
{
input: `BEGIN:VEVENT
UID:12345-abcde-67890
SUMMARY:Test Event
END:VEVENT`,
expected: '12345-abcde-67890',
},
{
input: `BEGIN:VEVENT
SUMMARY:No UID Event
END:VEVENT`,
shouldBeUUID: true,
},
{
input: `BEGIN:VEVENT
UID: spaced-uid
SUMMARY:Spaced UID
END:VEVENT`,
expected: 'spaced-uid',
},
];
for (const test of tests) {
const output = getIdFromVEvent(test.input);
if (test.expected) {
assertEquals(output, test.expected);
} else if (test.shouldBeUUID) {
assertMatch(output, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
}
}
});
Deno.test('that splitTextIntoVEvents works', () => {
const tests: { input: string; expected: string[] }[] = [
{
input: `BEGIN:VEVENT
UID:1
SUMMARY:Event 1
END:VEVENT
BEGIN:VEVENT
UID:2
SUMMARY:Event 2
END:VEVENT`,
expected: [
`BEGIN:VEVENT
UID:1
SUMMARY:Event 1
END:VEVENT`,
`BEGIN:VEVENT
UID:2
SUMMARY:Event 2
END:VEVENT`,
],
},
{
input: `BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
BEGIN:VEVENT
UID:1
SUMMARY:Event 1
END:VEVENT
BEGIN:VEVENT
UID:2
SUMMARY:Event 2
END:VEVENT
END:VCALENDAR`,
expected: [
`BEGIN:VEVENT
UID:1
SUMMARY:Event 1
END:VEVENT`,
`BEGIN:VEVENT
UID:2
SUMMARY:Event 2
END:VEVENT`,
],
},
{
input: `BEGIN:VEVENT
UID:single
SUMMARY:Single Event
END:VEVENT`,
expected: [
`BEGIN:VEVENT
UID:single
SUMMARY:Single Event
END:VEVENT`,
],
},
{
input: '',
expected: [],
},
{
input: `BEGIN:VEVENT
UID:incomplete
SUMMARY:Incomplete Event`,
expected: [],
},
];
for (const test of tests) {
const output = splitTextIntoVEvents(test.input);
assertEquals(output, test.expected);
}
});
Deno.test('that getDateRangeForCalendarView works', () => {
const baseDate = '2025-01-15';
const dayRange = getDateRangeForCalendarView(baseDate, 'day');
assertEquals(dayRange.start.getDate(), 14); // Previous day
assertEquals(dayRange.end.getDate(), 16); // Next day
const weekRange = getDateRangeForCalendarView(baseDate, 'week');
assertEquals(weekRange.start.getDate(), 8); // 7 days before
assertEquals(weekRange.end.getDate(), 22); // 7 days after
const monthRange = getDateRangeForCalendarView(baseDate, 'month');
assertEquals(monthRange.start.getDate(), 8); // 7 days before
assertEquals(monthRange.end.getDate(), 15); // 31 days after (wraps to next month)
});
Deno.test('that generateVEvent works', () => {
const testEvents: {
input: {
calendarEvent: CalendarEvent;
createdDate: Date;
};
expected: string;
}[] = [
{
input: {
calendarEvent: {
calendarId: 'test-calendar',
isAllDay: false,
url: 'test-123.ics',
uid: 'test-123',
title: 'Test Event',
startDate: new Date('2025-01-15T10:00:00Z'),
endDate: new Date('2025-01-15T11:00:00Z'),
organizerEmail: 'test@example.com',
transparency: 'opaque',
isRecurring: false,
recurringRrule: undefined,
sequence: 0,
description: 'Test description',
location: 'Test location',
eventUrl: 'https://example.com',
attendees: [
{ email: 'attendee@example.com', status: 'accepted', name: 'Test Attendee' },
],
reminders: [
{ type: 'display', startDate: '2025-01-15T09:45:00Z', description: 'Test reminder' },
],
},
createdDate: new Date('2025-01-15T10:00:00Z'),
},
expected: `BEGIN:VEVENT
DTSTAMP:20250115T100000
DTSTART:20250115T100000
DTEND:20250115T110000
ORGANIZER;CN=:mailto:test@example.com
SUMMARY:Test Event
TRANSP:OPAQUE
UID:test-123
SEQUENCE:0
CREATED:20250115T100000
LAST-MODIFIED:20250115T100000
DESCRIPTION:Test description
LOCATION:Test location
URL:https://example.com
ATTENDEE;PARTSTAT=ACCEPTED;CN=Test Attendee:mailto:attendee@example.com
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:Test reminder
TRIGGER;VALUE=DATE-TIME:20250115T094500
END:VALARM
END:VEVENT`,
},
{
input: {
calendarEvent: {
calendarId: 'test-calendar',
isAllDay: true,
url: 'test-123.ics',
uid: 'test-123',
title: 'Test Event',
startDate: new Date('2025-01-15T10:00:00Z'),
endDate: new Date('2025-01-15T11:00:00Z'),
organizerEmail: 'test@example.com',
transparency: 'opaque',
isRecurring: false,
recurringRrule: undefined,
sequence: 0,
description: 'Test description',
location: 'Test location',
eventUrl: 'https://example.com',
attendees: [
{ email: 'attendee@example.com', status: 'accepted', name: 'Test Attendee' },
],
reminders: [
{ type: 'display', startDate: '2025-01-15T09:45:00Z', description: 'Test reminder' },
],
},
createdDate: new Date('2025-01-15T10:00:00Z'),
},
expected: `BEGIN:VEVENT
DTSTAMP:20250115T100000
DTSTART;VALUE=DATE:20250115
DTEND;VALUE=DATE:20250115
ORGANIZER;CN=:mailto:test@example.com
SUMMARY:Test Event
TRANSP:OPAQUE
UID:test-123
SEQUENCE:0
CREATED:20250115T100000
LAST-MODIFIED:20250115T100000
DESCRIPTION:Test description
LOCATION:Test location
URL:https://example.com
ATTENDEE;PARTSTAT=ACCEPTED;CN=Test Attendee:mailto:attendee@example.com
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:Test reminder
TRIGGER;VALUE=DATE-TIME:20250115T094500
END:VALARM
END:VEVENT`,
},
{
input: {
calendarEvent: {
calendarId: 'test-calendar',
isAllDay: false,
url: 'test-123.ics',
uid: 'test-123',
title: 'Test Event',
startDate: new Date('2025-01-15T10:00:00Z'),
endDate: new Date('2025-01-15T11:00:00Z'),
organizerEmail: 'test@example.com',
transparency: 'opaque',
isRecurring: true,
recurringRrule: 'FREQ=WEEKLY;BYDAY=MO',
sequence: 1,
},
createdDate: new Date('2025-01-15T10:00:00Z'),
},
expected: `BEGIN:VEVENT
DTSTAMP:20250115T100000
DTSTART:20250115T100000
DTEND:20250115T110000
ORGANIZER;CN=:mailto:test@example.com
SUMMARY:Test Event
TRANSP:OPAQUE
UID:test-123
RRULE:FREQ=WEEKLY;BYDAY=MO
SEQUENCE:1
CREATED:20250115T100000
LAST-MODIFIED:20250115T100000
END:VEVENT`,
},
];
for (const testEvent of testEvents) {
const output = generateVEvent(testEvent.input.calendarEvent, testEvent.input.createdDate);
assertEquals(output, testEvent.expected);
}
});
Deno.test('that generateVCalendar works', () => {
const testEvents: CalendarEvent[] = [
{
calendarId: 'test-calendar',
isAllDay: false,
url: 'test-123.ics',
uid: 'event-1',
title: 'Event 1',
startDate: new Date('2025-01-15T10:00:00Z'),
endDate: new Date('2025-01-15T11:00:00Z'),
organizerEmail: 'test@example.com',
transparency: 'opaque',
isRecurring: false,
recurringRrule: undefined,
sequence: 0,
},
{
calendarId: 'test-calendar',
isAllDay: true,
url: 'test-123.ics',
uid: 'event-2',
title: 'Event 2',
startDate: new Date('2025-01-16T10:00:00Z'),
endDate: new Date('2025-01-16T11:00:00Z'),
organizerEmail: 'test@example.com',
transparency: 'opaque',
isRecurring: false,
recurringRrule: undefined,
sequence: 0,
},
];
const output = generateVCalendar(testEvents, new Date('2025-01-15T10:00:00Z'));
const expected = `BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
BEGIN:VEVENT
DTSTAMP:20250115T100000
DTSTART:20250115T100000
DTEND:20250115T110000
ORGANIZER;CN=:mailto:test@example.com
SUMMARY:Event 1
TRANSP:OPAQUE
UID:event-1
SEQUENCE:0
CREATED:20250115T100000
LAST-MODIFIED:20250115T100000
END:VEVENT
BEGIN:VEVENT
DTSTAMP:20250115T100000
DTSTART;VALUE=DATE:20250116
DTEND;VALUE=DATE:20250116
ORGANIZER;CN=:mailto:test@example.com
SUMMARY:Event 2
TRANSP:OPAQUE
UID:event-2
SEQUENCE:0
CREATED:20250115T100000
LAST-MODIFIED:20250115T100000
END:VEVENT
END:VCALENDAR`;
assertEquals(output, expected);
});
Deno.test('that updateIcs works', () => {
const testEvents: {
input: {
originalIcs: string;
updates: CalendarEvent;
};
expected: string;
}[] = [
{
input: {
originalIcs: `BEGIN:VEVENT
UID:test-123
SUMMARY:Original Title
DTSTART:20250115T100000Z
DTEND:20250115T110000Z
END:VEVENT`,
updates: {
calendarId: 'test-calendar',
isAllDay: false,
url: 'test-123.ics',
uid: 'test-123',
title: 'Updated Title',
startDate: new Date('2025-01-16T10:00:00Z'),
endDate: new Date('2025-01-16T11:00:00Z'),
organizerEmail: 'test@example.com',
transparency: 'transparent',
isRecurring: false,
recurringRrule: undefined,
sequence: 0,
description: 'New description',
location: 'New location',
eventUrl: 'https://updated.com',
},
},
expected: `BEGIN:VEVENT
UID:test-123
SUMMARY:Updated Title
DTSTART:20250116T100000
DTEND:20250116T110000
DESCRIPTION:New description
URL:https://updated.com
LOCATION:New location
END:VEVENT`,
},
{
input: {
originalIcs: `BEGIN:VEVENT
UID:test-123
SUMMARY:Original Title
DTSTART:20250115T100000
DTEND:20250115T110000
URL:https://example.com
LOCATION:Example location
END:VEVENT`,
updates: {
calendarId: 'test-calendar',
isAllDay: true,
url: 'test-123.ics',
uid: 'test-123',
title: 'Updated Title',
startDate: new Date('2025-01-16T10:00:00Z'),
endDate: new Date('2025-01-16T11:00:00Z'),
organizerEmail: 'test@example.com',
transparency: 'transparent',
isRecurring: false,
recurringRrule: undefined,
sequence: 0,
description: 'New description',
eventUrl: 'https://updated.com',
},
},
expected: `BEGIN:VEVENT
UID:test-123
SUMMARY:Updated Title
DTSTART;VALUE=DATE:20250116
DTEND;VALUE=DATE:20250116
URL:https://updated.com
LOCATION:Example location
DESCRIPTION:New description
END:VEVENT`,
},
{
input: {
originalIcs: `BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
PRODID:-//PYVOBJECT//NONSGML Version 0.9.9//EN
BEGIN:VEVENT
UID:test-123
SUMMARY:Original Title
DTSTART:20250115T100000
DTEND:20250115T110000
URL:https://example.com
LOCATION:Example location
END:VEVENT
END:VCALENDAR`,
updates: {
calendarId: 'test-calendar',
isAllDay: true,
url: 'test-123.ics',
uid: 'test-123',
title: 'Updated Title',
startDate: new Date('2025-01-16T10:00:00Z'),
endDate: new Date('2025-01-16T11:00:00Z'),
organizerEmail: 'test@example.com',
transparency: 'transparent',
isRecurring: false,
recurringRrule: undefined,
sequence: 0,
description: 'New description',
eventUrl: 'https://updated.com',
},
},
expected: `BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
PRODID:-//PYVOBJECT//NONSGML Version 0.9.9//EN
BEGIN:VEVENT
UID:test-123
SUMMARY:Updated Title
DTSTART;VALUE=DATE:20250116
DTEND;VALUE=DATE:20250116
URL:https://updated.com
LOCATION:Example location
DESCRIPTION:New description
END:VEVENT
END:VCALENDAR`,
},
{
input: {
originalIcs: `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
BEGIN:VTIMEZONE
TZID:Europe/Lisbon
BEGIN:STANDARD
DTSTART:19111231T232315
RDATE:19111231T232315
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:-003645
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19161101T010000
RDATE:19161101T010000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19171015T000000
RDATE:19171015T000000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19181015T000000
RDATE:19181015T000000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19191015T000000
RDATE:19191015T000000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19201015T000000
RDATE:19201015T000000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19211015T000000
RDATE:19211015T000000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19241005T000000
RDATE:19241005T000000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19261003T000000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19291006T000000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19311004T000000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19321002T000000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19341007T000000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19381002T000000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19391119T000000
RDATE:19391119T000000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19401008T000000
RDATE:19401008T000000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19411006T000000
RDATE:19411006T000000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19421025T000000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;UNTIL=19451028T000000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19461006T000000
RDATE:19461006T000000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19471005T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19651003T030000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19661002T030000
RDATE:19661002T030000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+010000
END:STANDARD
BEGIN:STANDARD
DTSTART:19760926T010000
RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1SU;UNTIL=19770925T010000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19781001T020000
RDATE:19781001T020000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19790930T020000
RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1SU;UNTIL=19800928T020000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19810927T010000
RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1SU;UNTIL=19850929T010000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19860928T020000
RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1SU;UNTIL=19910929T020000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19920927T020000
RDATE:19920927T020000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+010000
END:STANDARD
BEGIN:STANDARD
DTSTART:19930926T030000
RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1SU;UNTIL=19950924T030000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+020000
TZOFFSETTO:+010000
END:STANDARD
BEGIN:STANDARD
DTSTART:19961027T020000
RDATE:19961027T020000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19971026T020000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
TZNAME:(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:19160617T230000
RDATE:19160617T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19170301T000000
RDATE:19170301T000000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19180301T000000
RDATE:19180301T000000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19190301T000000
RDATE:19190301T000000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19200301T000000
RDATE:19200301T000000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19210301T000000
RDATE:19210301T000000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19240416T230000
RDATE:19240416T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19260417T230000
RDATE:19260417T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19270409T230000
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=2SA;UNTIL=19280414T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19290420T230000
RDATE:19290420T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19310418T230000
RDATE:19310418T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19320402T230000
RDATE:19320402T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19340407T230000
RDATE:19340407T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19350330T230000
RDATE:19350330T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19360418T230000
RDATE:19360418T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19370403T230000
RDATE:19370403T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19380326T230000
RDATE:19380326T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19390415T230000
RDATE:19390415T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19400224T230000
RDATE:19400224T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19410405T230000
RDATE:19410405T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19420314T230000
RDATE:19420314T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19420425T230000
RDATE:19420425T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+010000
TZOFFSETTO:+020000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19420816T000000
RDATE:19420816T000000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+020000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19430313T230000
RDATE:19430313T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19430417T230000
RDATE:19430417T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+010000
TZOFFSETTO:+020000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19430829T000000
RDATE:19430829T000000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+020000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19440311T230000
RDATE:19440311T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19440422T230000
RDATE:19440422T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+010000
TZOFFSETTO:+020000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19440827T000000
RDATE:19440827T000000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+020000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19450310T230000
RDATE:19450310T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19450421T230000
RDATE:19450421T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+010000
TZOFFSETTO:+020000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19450826T000000
RDATE:19450826T000000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+020000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19460406T230000
RDATE:19460406T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19470406T020000
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU;UNTIL=19660403T020000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19770327T000000
RDATE:19770327T000000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19780402T010000
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU;UNTIL=19800406T010000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19810329T000000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU;UNTIL=19850331T000000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19860330T010000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU;UNTIL=19920329T010000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19930328T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU;UNTIL=19950326T020000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+010000
TZOFFSETTO:+020000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19960331T020000
RDATE:19960331T020000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+010000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19970330T010000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
TZNAME:(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
X-TZINFO:Europe/Lisbon[2025b]
END:VTIMEZONE
BEGIN:VEVENT
UID:99e15556-fd88-4cb9-818e-fcbf853bc443
DTSTART;TZID=Europe/Lisbon:20250720T090000
DTEND;TZID=Europe/Lisbon:20250720T100000
CREATED:20250720T075329Z
DTSTAMP:20250720T075414Z
RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;UNTIL=20250817T080000Z
SUMMARY:Recurring Standup
TRANSP:OPAQUE
END:VEVENT
END:VCALENDAR`,
updates: {
calendarId: 'test-calendar',
isAllDay: false,
url: '99e15556-fd88-4cb9-818e-fcbf853bc443.ics',
uid: '99e15556-fd88-4cb9-818e-fcbf853bc443',
title: 'Updated Title',
startDate: new Date('2025-07-20T09:00:00Z'),
endDate: new Date('2025-07-20T10:00:00Z'),
organizerEmail: 'test@example.com',
transparency: 'opaque',
isRecurring: true,
recurringRrule: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;UNTIL=20250817T080000Z',
sequence: 0,
description: 'New description',
},
},
expected: `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
BEGIN:VTIMEZONE
TZID:Europe/Lisbon
BEGIN:STANDARD
DTSTART:19111231T232315
RDATE:19111231T232315
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:-003645
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19161101T010000
RDATE:19161101T010000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19171015T000000
RDATE:19171015T000000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19181015T000000
RDATE:19181015T000000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19191015T000000
RDATE:19191015T000000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19201015T000000
RDATE:19201015T000000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19211015T000000
RDATE:19211015T000000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19241005T000000
RDATE:19241005T000000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19261003T000000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19291006T000000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19311004T000000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19321002T000000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19341007T000000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19381002T000000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19391119T000000
RDATE:19391119T000000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19401008T000000
RDATE:19401008T000000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19411006T000000
RDATE:19411006T000000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19421025T000000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;UNTIL=19451028T000000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19461006T000000
RDATE:19461006T000000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19471005T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19651003T030000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19661002T030000
RDATE:19661002T030000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+010000
END:STANDARD
BEGIN:STANDARD
DTSTART:19760926T010000
RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1SU;UNTIL=19770925T010000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19781001T020000
RDATE:19781001T020000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19790930T020000
RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1SU;UNTIL=19800928T020000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19810927T010000
RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1SU;UNTIL=19850929T010000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19860928T020000
RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1SU;UNTIL=19910929T020000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19920927T020000
RDATE:19920927T020000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+010000
END:STANDARD
BEGIN:STANDARD
DTSTART:19930926T030000
RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1SU;UNTIL=19950924T030000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+020000
TZOFFSETTO:+010000
END:STANDARD
BEGIN:STANDARD
DTSTART:19961027T020000
RDATE:19961027T020000
TZNAME:Europe/Lisbon(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19971026T020000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
TZNAME:(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:19160617T230000
RDATE:19160617T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19170301T000000
RDATE:19170301T000000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19180301T000000
RDATE:19180301T000000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19190301T000000
RDATE:19190301T000000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19200301T000000
RDATE:19200301T000000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19210301T000000
RDATE:19210301T000000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19240416T230000
RDATE:19240416T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19260417T230000
RDATE:19260417T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19270409T230000
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=2SA;UNTIL=19280414T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19290420T230000
RDATE:19290420T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19310418T230000
RDATE:19310418T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19320402T230000
RDATE:19320402T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19340407T230000
RDATE:19340407T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19350330T230000
RDATE:19350330T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19360418T230000
RDATE:19360418T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19370403T230000
RDATE:19370403T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19380326T230000
RDATE:19380326T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19390415T230000
RDATE:19390415T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19400224T230000
RDATE:19400224T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19410405T230000
RDATE:19410405T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19420314T230000
RDATE:19420314T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19420425T230000
RDATE:19420425T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+010000
TZOFFSETTO:+020000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19420816T000000
RDATE:19420816T000000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+020000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19430313T230000
RDATE:19430313T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19430417T230000
RDATE:19430417T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+010000
TZOFFSETTO:+020000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19430829T000000
RDATE:19430829T000000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+020000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19440311T230000
RDATE:19440311T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19440422T230000
RDATE:19440422T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+010000
TZOFFSETTO:+020000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19440827T000000
RDATE:19440827T000000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+020000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19450310T230000
RDATE:19450310T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19450421T230000
RDATE:19450421T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+010000
TZOFFSETTO:+020000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19450826T000000
RDATE:19450826T000000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+020000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19460406T230000
RDATE:19460406T230000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19470406T020000
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU;UNTIL=19660403T020000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19770327T000000
RDATE:19770327T000000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19780402T010000
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU;UNTIL=19800406T010000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19810329T000000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU;UNTIL=19850331T000000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19860330T010000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU;UNTIL=19920329T010000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19930328T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU;UNTIL=19950326T020000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+010000
TZOFFSETTO:+020000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19960331T020000
RDATE:19960331T020000
TZNAME:Europe/Lisbon(DST)
TZOFFSETFROM:+010000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19970330T010000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
TZNAME:(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
X-TZINFO:Europe/Lisbon[2025b]
END:VTIMEZONE
BEGIN:VEVENT
UID:99e15556-fd88-4cb9-818e-fcbf853bc443
DTSTART:20250720T090000
DTEND:20250720T100000
CREATED:20250720T075329Z
DTSTAMP:20250720T075414Z
RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;UNTIL=20250817T080000Z
SUMMARY:Updated Title
TRANSP:OPAQUE
DESCRIPTION:New description
END:VEVENT
END:VCALENDAR`,
},
];
for (const test of testEvents) {
const output = updateIcs(test.input.originalIcs, test.input.updates);
assertEquals(output, test.expected);
}
});
Deno.test('that parseVCalendar works', () => {
const testIcs = `BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
BEGIN:VEVENT
UID:test-123
SUMMARY:Test Event
DTSTART:20250115T100000Z
DTEND:20250115T110000Z
ORGANIZER;CN=:mailto:test@example.com
TRANSP:OPAQUE
DESCRIPTION:Test description
LOCATION:Test location
URL:https://example.com
ATTENDEE;PARTSTAT=ACCEPTED;CN=Test Attendee:mailto:attendee@example.com
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:Test reminder
TRIGGER;VALUE=DATE-TIME:20250115T094500Z
END:VALARM
END:VEVENT
END:VCALENDAR`;
const output = parseVCalendar(testIcs);
assertEquals(output.length, 1);
const event = output[0];
assertEquals(event.uid, 'test-123');
assertEquals(event.title, 'Test Event');
assertEquals(event.description, 'Test description');
assertEquals(event.location, 'Test location');
assertEquals(event.eventUrl, 'https://example.com');
assertEquals(event.transparency, 'opaque');
assertEquals(event.attendees?.length, 1);
assertEquals(event.reminders?.length, 1);
});
Deno.test('that parseVCalendar handles multiple events', () => {
const testIcs = `BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
UID:event-1
SUMMARY:Event 1
DTSTART:20250115T100000Z
DTEND:20250115T110000Z
END:VEVENT
BEGIN:VEVENT
UID:event-2
SUMMARY:Event 2
DTSTART:20250116T100000Z
DTEND:20250116T110000Z
END:VEVENT
END:VCALENDAR`;
const output = parseVCalendar(testIcs);
assertEquals(output.length, 2);
assertEquals(output[0].uid, 'event-1');
assertEquals(output[1].uid, 'event-2');
});
Deno.test('that parseVCalendar handles recurring events', () => {
const testIcs = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
BEGIN:VEVENT
UID:99e15556-fd88-4cb9-818e-fcbf853bc443
RECURRENCE-ID:20250721T080000Z
DTSTART:20250721T080000Z
DTEND:20250721T090000Z
CREATED:20250720T075329Z
DTSTAMP:20250720T075414Z
LAST-MODIFIED:20250720T075414Z
SUMMARY:Recurring Standup
TRANSP:OPAQUE
END:VEVENT
BEGIN:VEVENT
UID:99e15556-fd88-4cb9-818e-fcbf853bc443
RECURRENCE-ID:20250722T080000Z
DTSTART:20250722T080000Z
DTEND:20250722T090000Z
CREATED:20250720T075329Z
DTSTAMP:20250720T075414Z
LAST-MODIFIED:20250720T075414Z
SUMMARY:Recurring Standup
TRANSP:OPAQUE
END:VEVENT
BEGIN:VEVENT
UID:99e15556-fd88-4cb9-818e-fcbf853bc443
RECURRENCE-ID:20250723T080000Z
DTSTART:20250723T080000Z
DTEND:20250723T090000Z
CREATED:20250720T075329Z
DTSTAMP:20250720T075414Z
LAST-MODIFIED:20250720T075414Z
SUMMARY:Recurring Standup
TRANSP:OPAQUE
END:VEVENT
BEGIN:VEVENT
UID:99e15556-fd88-4cb9-818e-fcbf853bc443
RECURRENCE-ID:20250724T080000Z
DTSTART:20250724T080000Z
DTEND:20250724T090000Z
CREATED:20250720T075329Z
DTSTAMP:20250720T075414Z
LAST-MODIFIED:20250720T075414Z
SUMMARY:Recurring Standup
TRANSP:OPAQUE
END:VEVENT
BEGIN:VEVENT
UID:99e15556-fd88-4cb9-818e-fcbf853bc443
RECURRENCE-ID:20250725T080000Z
DTSTART:20250725T080000Z
DTEND:20250725T090000Z
CREATED:20250720T075329Z
DTSTAMP:20250720T075414Z
LAST-MODIFIED:20250720T075414Z
SUMMARY:Recurring Standup
TRANSP:OPAQUE
END:VEVENT
BEGIN:VEVENT
UID:99e15556-fd88-4cb9-818e-fcbf853bc443
RECURRENCE-ID:20250728T080000Z
DTSTART:20250728T080000Z
DTEND:20250728T090000Z
CREATED:20250720T075329Z
DTSTAMP:20250720T075414Z
LAST-MODIFIED:20250720T075414Z
SUMMARY:Recurring Standup
TRANSP:OPAQUE
END:VEVENT
BEGIN:VEVENT
UID:99e15556-fd88-4cb9-818e-fcbf853bc443
RECURRENCE-ID:20250729T080000Z
DTSTART:20250729T080000Z
DTEND:20250729T090000Z
CREATED:20250720T075329Z
DTSTAMP:20250720T075414Z
LAST-MODIFIED:20250720T075414Z
SUMMARY:Recurring Standup
TRANSP:OPAQUE
END:VEVENT
BEGIN:VEVENT
UID:99e15556-fd88-4cb9-818e-fcbf853bc443
RECURRENCE-ID:20250730T080000Z
DTSTART:20250730T080000Z
DTEND:20250730T090000Z
CREATED:20250720T075329Z
DTSTAMP:20250720T075414Z
LAST-MODIFIED:20250720T075414Z
SUMMARY:Recurring Standup
TRANSP:OPAQUE
END:VEVENT
BEGIN:VEVENT
UID:99e15556-fd88-4cb9-818e-fcbf853bc443
RECURRENCE-ID:20250731T080000Z
DTSTART:20250731T080000Z
DTEND:20250731T090000Z
CREATED:20250720T075329Z
DTSTAMP:20250720T075414Z
LAST-MODIFIED:20250720T075414Z
SUMMARY:Recurring Standup
TRANSP:OPAQUE
END:VEVENT
BEGIN:VEVENT
UID:99e15556-fd88-4cb9-818e-fcbf853bc443
RECURRENCE-ID:20250801T080000Z
DTSTART:20250801T080000Z
DTEND:20250801T090000Z
CREATED:20250720T075329Z
DTSTAMP:20250720T075414Z
LAST-MODIFIED:20250720T075414Z
SUMMARY:Recurring Standup
TRANSP:OPAQUE
END:VEVENT
BEGIN:VEVENT
UID:99e15556-fd88-4cb9-818e-fcbf853bc443
RECURRENCE-ID:20250804T080000Z
DTSTART:20250804T080000Z
DTEND:20250804T090000Z
CREATED:20250720T075329Z
DTSTAMP:20250720T075414Z
LAST-MODIFIED:20250720T075414Z
SUMMARY:Recurring Standup
TRANSP:OPAQUE
END:VEVENT
END:VCALENDAR`;
const output = parseVCalendar(testIcs);
assertEquals(output.length, 11);
assertEquals(output[0].uid, '99e15556-fd88-4cb9-818e-fcbf853bc443:20250721T080000Z');
assertEquals(output[0].isRecurring, true);
assertEquals(output[0].recurrenceMasterUid, '99e15556-fd88-4cb9-818e-fcbf853bc443');
assertEquals(output[0].recurrenceId, '20250721T080000Z');
assertEquals(output[0].title, 'Recurring Standup');
assertEquals(output[1].uid, '99e15556-fd88-4cb9-818e-fcbf853bc443:20250722T080000Z');
assertEquals(output[1].isRecurring, true);
assertEquals(output[1].recurrenceMasterUid, '99e15556-fd88-4cb9-818e-fcbf853bc443');
assertEquals(output[1].recurrenceId, '20250722T080000Z');
assertEquals(output[1].title, 'Recurring Standup');
});
Deno.test('that getWeeksForMonth works', () => {
const testDate = new Date('2025-01-15');
const weeks = getWeeksForMonth(testDate);
// January 2025 starts on Wednesday, so it should have 5 weeks
assertEquals(weeks.length, 5);
// First week should start with December 30, 2024 (Monday)
assertEquals(weeks[0][0].date.getDate(), 30);
assertEquals(weeks[0][0].date.getMonth(), 11);
// Last week should end with February 2, 2025 (Sunday)
assertEquals(weeks[4][6].date.getDate(), 2);
assertEquals(weeks[4][6].date.getMonth(), 1);
});
Deno.test('that getDaysForWeek works', () => {
const testDate = new Date('2025-01-15');
const days = getDaysForWeek(testDate);
assertEquals(days.length, 7);
// Should start with Monday (January 13, 2025)
assertEquals(days[0].date.getDate(), 13);
assertEquals(days[0].date.getDay(), 1);
// Should end with Sunday (January 19, 2025)
assertEquals(days[6].date.getDate(), 19);
assertEquals(days[6].date.getDay(), 0);
// Each day should have 24 hours
assertEquals(days[0].hours.length, 24);
});
Deno.test('that getCalendarEventStyle works', () => {
const calendars: Calendar[] = [
{ url: 'cal-1', isVisible: true, uid: 'cal-1', name: 'Calendar 1', calendarColor: '#B51E1F' },
{ url: 'cal-2', isVisible: true, uid: 'cal-2', name: 'Calendar 2', calendarColor: '#1E3A89' },
];
const opaqueEvent: CalendarEvent = {
calendarId: 'cal-1',
isAllDay: false,
url: 'event-1.ics',
uid: 'event-1',
title: 'Opaque Event',
startDate: new Date(),
endDate: new Date(),
organizerEmail: 'test@example.com',
transparency: 'opaque',
isRecurring: false,
recurringRrule: undefined,
sequence: 0,
};
const transparentEvent: CalendarEvent = {
calendarId: 'cal-2',
isAllDay: false,
url: 'event-2.ics',
uid: 'event-2',
title: 'Transparent Event',
startDate: new Date(),
endDate: new Date(),
organizerEmail: 'test@example.com',
transparency: 'transparent',
isRecurring: false,
recurringRrule: undefined,
sequence: 0,
};
assertEquals(getCalendarEventStyle(opaqueEvent, calendars), { backgroundColor: '#B51E1F' });
assertEquals(getCalendarEventStyle(transparentEvent, calendars), { border: '1px solid #1E3A89' });
});
Deno.test('that getCalendarEventStyle returns default color for unknown calendar', () => {
const calendars: Calendar[] = [];
const event: CalendarEvent = {
calendarId: 'unknown-cal',
isAllDay: false,
url: 'event-1.ics',
uid: 'event-1',
title: 'Unknown Calendar Event',
startDate: new Date(),
endDate: new Date(),
organizerEmail: 'test@example.com',
transparency: 'opaque',
isRecurring: false,
recurringRrule: undefined,
sequence: 0,
};
assertEquals(getCalendarEventStyle(event, calendars), { backgroundColor: '#384354' });
});
Deno.test('that parseIcsDate works', () => {
const tests: { input: string; expected: string }[] = [
{ input: '20250101T000000Z', expected: '2025-01-01T00:00:00.000Z' },
{ input: '20250201T000300', expected: '2025-02-01T00:03:00.000Z' },
{ input: '20250103T050000', expected: '2025-01-03T05:00:00.000Z' },
];
for (const test of tests) {
const output = parseIcsDate(test.input);
assertEquals(output.toISOString(), test.expected);
}
});
Deno.test('that convertRRuleToWords works', () => {
const tests: { input: string; expected: string }[] = [
{ input: 'RRULE:FREQ=DAILY', expected: 'Every day' },
{ input: 'RRULE:FREQ=WEEKLY;BYDAY=MO', expected: 'Every week on Monday' },
{ input: 'RRULE:FREQ=MONTHLY;BYMONTHDAY=15', expected: 'Every month on the 15th' },
{ input: 'RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR', expected: 'Every week on Monday, Wednesday, Friday' },
{ input: 'RRULE:FREQ=MONTHLY;BYMONTHDAY=1;INTERVAL=2', expected: 'Every 2 months on the 1st' },
{ input: 'RRULE:FREQ=DAILY;COUNT=5', expected: 'Every day for 5 times' },
{
input: 'RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=20250101T000000Z',
expected: 'Every week on Monday, Wednesday, Friday until 2025-01-01',
},
{
input: 'RRULE:FREQ=MONTHLY;BYMONTHDAY=1;INTERVAL=2;UNTIL=20250101T000000Z',
expected: 'Every 2 months on the 1st until 2025-01-01',
},
];
for (const test of tests) {
const output = convertRRuleToWords(test.input);
assertEquals(output, test.expected);
}
});
Deno.test('that convertRRuleToWords handles invalid rules', () => {
const output = convertRRuleToWords('INVALID:RULE');
assertEquals(output, '');
});
Deno.test('that parseVCalendar handles vCalendar 1.0', () => {
const testIcs = `BEGIN:VCALENDAR
VERSION:1.0
BEGIN:VEVENT
UID:test-123
SUMMARY:Test Event
DTSTART:20250115T100000Z
DTEND:20250115T110000Z
END:VEVENT
END:VCALENDAR`;
const output = parseVCalendar(testIcs);
assertEquals(output.length, 1);
assertEquals(output[0].uid, 'test-123');
});
Deno.test('that parseVCalendar handles multiline fields', () => {
const testIcs = `BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
UID:test-123
SUMMARY:Test Event with
long title
DESCRIPTION:Test description with very
long text.\\n\\nAnd a new line.
DTSTART:20250115T100000Z
DTEND:20250115T110000Z
END:VEVENT
END:VCALENDAR`;
const output = parseVCalendar(testIcs);
assertEquals(output.length, 1);
assertEquals(output[0].title, 'Test Event with long title');
assertEquals(output[0].description, 'Test description with very long text.\n\nAnd a new line.');
});
Deno.test('that parseVCalendar handles empty fields gracefully', () => {
const testIcs = `BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
UID:test-123
SUMMARY:
DESCRIPTION:
LOCATION:
END:VEVENT
END:VCALENDAR`;
const output = parseVCalendar(testIcs);
assertEquals(output.length, 1);
assertEquals(output[0].uid, 'test-123');
assertEquals(output[0].title, undefined);
assertEquals(output[0].location, undefined);
assertEquals(output[0].description, undefined);
});