Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions integrations/calendar/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,20 @@ describe('Google Calendar package', () => {
end: '2026-09-23',
allDay: true,
});
expect(() =>
// end == start, as Google Calendar shows it: one day, exclusive end.
expect(
project(
timed('e3', {
start: { date: '2026-12-31' },
end: { date: '2026-12-31' },
}),
),
).toMatchObject({ start: '2026-12-31', end: '2027-01-01', allDay: true });
expect(() =>
project(
timed('e3b', {
start: { date: '2026-09-22' },
end: { date: '2026-09-22' },
end: { date: '2026-09-21' },
}),
),
).toThrow('invalid all-day interval');
Expand All @@ -139,11 +148,11 @@ describe('Google Calendar package', () => {
});

it('skips and lists an unreadable event instead of failing the whole scan', async () => {
// Seen in user testing (2026-09-28): one all-day event failed every sync.
// In user testing (2026-09-28) one odd all-day event failed every sync.
const bad = timed('bad', {
summary: 'testing',
start: { date: '2026-04-02' },
end: { date: '2026-04-02' },
end: { date: '2026-04-01' },
});
const result = await preview(
{
Expand Down Expand Up @@ -185,7 +194,7 @@ describe('Google Calendar package', () => {
id: 'bad',
title: 'testing',
reason:
'Calendar event bad has an invalid all-day interval (start "2026-04-02", end "2026-04-02")',
'Calendar event bad has an invalid all-day interval (start "2026-04-02", end "2026-04-01")',
},
]);
// Its existing row is kept: a conflict, never an inferred deletion.
Expand Down
18 changes: 16 additions & 2 deletions integrations/calendar/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,18 @@ function civilDate(value: string): boolean {
);
}

/** Google's own writes give an all-day event an exclusive end, the day after
* its last day. Events with end == start exist too (seen in user testing,
* 2026-09-28: all-day events written with an inclusive end), and Google
* Calendar shows them as one day, so they are read as that one day. Any
* write-back then sends the exclusive end. */
function allDayEnd(start: string, end: string): string {
if (end !== start) return end;
const next = new Date(Date.parse(`${start}T00:00:00Z`) + 86_400_000);

return next.toISOString().slice(0, 10);
}

function offsetDateTime(value: string): boolean {
return (
/^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/.test(value) &&
Expand Down Expand Up @@ -146,7 +158,7 @@ export function project(event: Event): Projection | undefined {
!civilDate(event.start.date) ||
!event.end.date ||
!civilDate(event.end.date) ||
event.end.date <= event.start.date
event.end.date < event.start.date
)
throw new UnreadableEventError(
`Calendar event ${event.id} has an invalid all-day interval (${interval()})`,
Expand All @@ -171,7 +183,9 @@ export function project(event: Event): Projection | undefined {
description: event.description ?? '',
location: event.location ?? '',
start: allDay ? event.start.date! : event.start.dateTime!,
end: allDay ? event.end.date! : event.end.dateTime!,
end: allDay
? allDayEnd(event.start.date!, event.end.date!)
: event.end.dateTime!,
allDay,
};
}
Expand Down
Loading