Skip to content

Commit 289cd83

Browse files
committed
Fixed many issues with recurring activity
1 parent 53585ca commit 289cd83

9 files changed

Lines changed: 620 additions & 133 deletions

File tree

.changeset/orange-adults-switch.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@plotday/tool-outlook-calendar": minor
3+
"@plotday/tool-google-calendar": minor
4+
"@plotday/twister": minor
5+
---
6+
7+
Fixed: BREAKING: Fixed many issues with recurring activity, which required some changes to ActivityOccurrence

tools/google-calendar/src/google-api.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -405,9 +405,14 @@ export function transformGoogleEvent(
405405
activity.recurrenceExdates = exdates;
406406
}
407407

408+
// Parse RDATEs (additional occurrence dates not in the recurrence rule)
409+
// and create ActivityOccurrenceUpdate entries for each
408410
const rdates = parseRDates(event.recurrence);
409411
if (rdates.length > 0) {
410-
activity.recurrenceDates = rdates;
412+
activity.occurrences = rdates.map((rdate) => ({
413+
occurrence: rdate,
414+
start: rdate,
415+
}));
411416
}
412417
}
413418

tools/google-calendar/src/google-calendar.ts

Lines changed: 141 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import {
33
type Activity,
44
type ActivityLink,
55
ActivityLinkType,
6+
type ActivityOccurrence,
7+
type NewActivityOccurrence,
68
type ActorId,
9+
ActivityType,
710
ConferencingProvider,
811
type NewActivityWithNotes,
912
type NewActor,
@@ -432,8 +435,6 @@ export class GoogleCalendar
432435
initialSync: boolean
433436
): Promise<void> {
434437
// Get user email for RSVP tagging
435-
const userEmail = await this.get<string>("user_email");
436-
437438
for (const event of events) {
438439
try {
439440
if (event.status === "cancelled") {
@@ -463,14 +464,16 @@ export class GoogleCalendar
463464

464465
// Check if this is a recurring event instance (exception)
465466
if (event.recurringEventId && event.originalStartTime) {
466-
await this.processEventException(event, calendarId, initialSync);
467+
await this.processEventInstance(event, calendarId, initialSync);
467468
} else {
468469
// Regular or master recurring event
469470
const activityData = transformGoogleEvent(event, calendarId);
470471

471-
// Determine RSVP status for all attendees and set tags with NewActor[]
472+
// For recurring events, DON'T add tags at series level
473+
// Tags (RSVPs) should be per-occurrence via the occurrences array
474+
// For non-recurring events, add tags normally
472475
let tags: Partial<Record<Tag, NewActor[]>> | null = null;
473-
if (validAttendees.length > 0) {
476+
if (validAttendees.length > 0 && !activityData.recurrenceRule) {
474477
const attendTags: NewActor[] = [];
475478
const skipTags: NewActor[] = [];
476479
const undecidedTags: NewActor[] = [];
@@ -572,6 +575,7 @@ export class GoogleCalendar
572575
links: hasLinks ? links : null,
573576
contentType:
574577
description && containsHtml(description) ? "html" : "text",
578+
created: event.created ? new Date(event.created) : new Date(),
575579
});
576580
}
577581

@@ -588,9 +592,6 @@ export class GoogleCalendar
588592
author: authorContact,
589593
recurrenceRule: activityData.recurrenceRule || null,
590594
recurrenceExdates: activityData.recurrenceExdates || null,
591-
recurrenceDates: activityData.recurrenceDates || null,
592-
recurrence: null,
593-
occurrence: null,
594595
meta: activityData.meta ?? null,
595596
tags: tags || undefined,
596597
notes,
@@ -607,109 +608,137 @@ export class GoogleCalendar
607608
}
608609
}
609610

610-
private async processEventException(
611+
/**
612+
* Process a recurring event instance (occurrence) from Google Calendar.
613+
* This updates the master recurring activity with occurrence-specific data.
614+
*/
615+
private async processEventInstance(
611616
event: GoogleEvent,
612617
calendarId: string,
613618
initialSync: boolean
614619
): Promise<void> {
615-
// Similar to processCalendarEvents but for exceptions
616-
// This would find the master recurring activity and create an exception
620+
console.log(`[processEventInstance] Processing event instance:`, {
621+
id: event.id,
622+
recurringEventId: event.recurringEventId,
623+
summary: event.summary,
624+
status: event.status,
625+
start: event.start,
626+
end: event.end,
627+
originalStartTime: event.originalStartTime,
628+
});
629+
617630
const originalStartTime =
618631
event.originalStartTime?.dateTime || event.originalStartTime?.date;
619632
if (!originalStartTime) {
620-
console.warn(`No original start time for exception: ${event.id}`);
633+
console.warn(`No original start time for instance: ${event.id}`);
621634
return;
622635
}
623636

624-
const activityData = transformGoogleEvent(event, calendarId);
625-
626-
const callbackToken = await this.get<Callback>("event_callback_token");
627-
if (!callbackToken || !activityData.type) {
637+
// The recurring event ID points to the master activity
638+
if (!event.recurringEventId) {
639+
console.warn(`No recurring event ID for instance: ${event.id}`);
628640
return;
629641
}
630642

631-
// Build links array for videoconferencing and calendar links
632-
const links: ActivityLink[] = [];
633-
const seenUrls = new Set<string>();
634-
635-
// Extract all conferencing links (Zoom, Teams, Webex, etc.)
636-
const conferencingLinks = extractConferencingLinks(event);
637-
for (const link of conferencingLinks) {
638-
if (!seenUrls.has(link.url)) {
639-
seenUrls.add(link.url);
640-
links.push({
641-
type: ActivityLinkType.conferencing,
642-
url: link.url,
643-
provider: link.provider,
644-
});
645-
}
646-
}
643+
// Canonical URL for the master recurring event
644+
const masterCanonicalUrl = `google-calendar:${calendarId}:${event.recurringEventId}`;
647645

648-
// Add Google Meet link from hangoutLink if not already added
649-
if (event.hangoutLink && !seenUrls.has(event.hangoutLink)) {
650-
seenUrls.add(event.hangoutLink);
651-
links.push({
652-
type: ActivityLinkType.conferencing,
653-
url: event.hangoutLink,
654-
provider: ConferencingProvider.googleMeet,
646+
// Transform the instance data
647+
const instanceData = transformGoogleEvent(event, calendarId);
648+
649+
console.log(`[processEventInstance] Transformed instance data:`, {
650+
type: instanceData.type,
651+
title: instanceData.title,
652+
start: instanceData.start,
653+
end: instanceData.end,
654+
recurrenceRule: instanceData.recurrenceRule,
655+
meta: instanceData.meta,
656+
});
657+
658+
// Determine RSVP status for attendees
659+
const validAttendees =
660+
event.attendees?.filter((att) => att.email && !att.resource) || [];
661+
662+
let tags: Partial<Record<Tag, import("@plotday/twister").NewActor[]>> = {};
663+
if (validAttendees.length > 0) {
664+
const attendTags: import("@plotday/twister").NewActor[] = [];
665+
const skipTags: import("@plotday/twister").NewActor[] = [];
666+
const undecidedTags: import("@plotday/twister").NewActor[] = [];
667+
668+
validAttendees.forEach((attendee) => {
669+
const newActor: import("@plotday/twister").NewActor = {
670+
email: attendee.email!,
671+
name: attendee.displayName,
672+
};
673+
674+
if (attendee.responseStatus === "accepted") {
675+
attendTags.push(newActor);
676+
} else if (attendee.responseStatus === "declined") {
677+
skipTags.push(newActor);
678+
} else if (
679+
attendee.responseStatus === "tentative" ||
680+
attendee.responseStatus === "needsAction"
681+
) {
682+
undecidedTags.push(newActor);
683+
}
655684
});
685+
686+
if (attendTags.length > 0) tags[Tag.Attend] = attendTags;
687+
if (skipTags.length > 0) tags[Tag.Skip] = skipTags;
688+
if (undecidedTags.length > 0) tags[Tag.Undecided] = undecidedTags;
656689
}
657690

658-
// Add calendar link
659-
if (event.htmlLink) {
660-
links.push({
661-
type: ActivityLinkType.external,
662-
title: "View in Calendar",
663-
url: event.htmlLink,
664-
});
691+
// Build occurrence object
692+
// Always include start to ensure upsert_activity can infer scheduling when
693+
// creating a new master activity. Use instanceData.start if available (for
694+
// rescheduled instances), otherwise fall back to originalStartTime.
695+
const occurrenceStart = instanceData.start ?? new Date(originalStartTime);
696+
697+
const occurrence: Omit<NewActivityOccurrence, "activity"> = {
698+
occurrence: new Date(originalStartTime),
699+
start: occurrenceStart,
700+
tags: Object.keys(tags).length > 0 ? tags : undefined,
701+
unread: !initialSync,
702+
};
703+
704+
// Add additional field overrides if present
705+
if (instanceData.end !== undefined && instanceData.end !== null) {
706+
occurrence.end = instanceData.end;
665707
}
708+
if (instanceData.title) occurrence.title = instanceData.title;
709+
if (instanceData.meta) occurrence.meta = instanceData.meta;
666710

667-
// Prepare description content
668-
const descriptionValue =
669-
activityData.meta?.description || event.description;
670-
const description =
671-
typeof descriptionValue === "string" ? descriptionValue : null;
672-
const hasDescription = description && description.trim().length > 0;
673-
const hasLinks = links.length > 0;
674-
675-
// Canonical URL for this event (required for upsert)
676-
const canonicalUrl =
677-
event.htmlLink || `google-calendar:${calendarId}:${event.id}`;
678-
679-
// Create note with description and/or links
680-
const notes: NewNote[] = [];
681-
if (hasDescription || hasLinks) {
682-
notes.push({
683-
activity: { source: canonicalUrl },
684-
key: "description",
685-
content: hasDescription ? description : null,
686-
links: hasLinks ? links : null,
687-
contentType: description && containsHtml(description) ? "html" : "text",
688-
});
711+
// Send occurrence data to the twist via callback
712+
// The twist will decide whether to create or update the master activity
713+
const callbackToken = await this.get<Callback>("event_callback_token");
714+
if (!callbackToken) {
715+
console.warn("No callback token found for occurrence update");
716+
return;
689717
}
690718

691-
const activity: NewActivityWithNotes = {
692-
source: canonicalUrl,
693-
type: activityData.type,
694-
created: event.created ? new Date(event.created) : undefined,
695-
start: activityData.start || null,
696-
end: activityData.end || null,
697-
recurrenceUntil: activityData.recurrenceUntil || null,
698-
recurrenceCount: activityData.recurrenceCount || null,
699-
done: null,
700-
title: activityData.title || "",
701-
recurrenceRule: null,
702-
recurrenceExdates: null,
703-
recurrenceDates: null,
704-
recurrence: null, // Would need to find master activity
705-
occurrence: new Date(originalStartTime),
706-
meta: activityData.meta ?? null,
707-
notes,
708-
unread: !initialSync, // false for initial sync, true for incremental updates
719+
// Build a minimal NewActivity with source and occurrences
720+
// The twist's createActivity will upsert the master activity
721+
const occurrenceUpdate = {
722+
type: ActivityType.Event,
723+
source: masterCanonicalUrl,
724+
occurrences: [occurrence],
709725
};
710726

711-
// Send activity - database handles upsert automatically
712-
await this.tools.callbacks.run(callbackToken, activity);
727+
console.log(`[processEventInstance] Sending occurrence update:`, {
728+
source: occurrenceUpdate.source,
729+
type: occurrenceUpdate.type,
730+
occurrenceCount: occurrenceUpdate.occurrences.length,
731+
occurrence: {
732+
occurrence: occurrence.occurrence,
733+
start: occurrence.start,
734+
end: occurrence.end,
735+
title: occurrence.title,
736+
hasOriginalStart: !!originalStartTime,
737+
instanceDataStart: instanceData.start,
738+
},
739+
});
740+
741+
await this.tools.callbacks.run(callbackToken, occurrenceUpdate);
713742
}
714743

715744
async onCalendarWebhook(
@@ -840,6 +869,7 @@ export class GoogleCalendar
840869
changes: {
841870
tagsAdded: Record<Tag, ActorId[]>;
842871
tagsRemoved: Record<Tag, ActorId[]>;
872+
occurrence?: ActivityOccurrence;
843873
}
844874
): Promise<void> {
845875
// Only process calendar events
@@ -914,13 +944,13 @@ export class GoogleCalendar
914944
return;
915945
}
916946

917-
const eventId = activity.meta.id;
947+
const baseEventId = activity.meta.id;
918948
const calendarId = activity.meta.calendarId;
919949

920950
if (
921-
!eventId ||
951+
!baseEventId ||
922952
!calendarId ||
923-
typeof eventId !== "string" ||
953+
typeof baseEventId !== "string" ||
924954
typeof calendarId !== "string"
925955
) {
926956
console.warn(
@@ -929,6 +959,26 @@ export class GoogleCalendar
929959
return;
930960
}
931961

962+
// Determine the event ID to update
963+
// If this is an occurrence-level change, construct the instance ID
964+
let eventId = baseEventId;
965+
if (changes.occurrence) {
966+
// Google Calendar instance IDs are formatted as: {recurringEventId}_{YYYYMMDDTHHMMSSZ}
967+
const occurrenceDate =
968+
changes.occurrence.occurrence instanceof Date
969+
? changes.occurrence.occurrence
970+
: new Date(changes.occurrence.occurrence);
971+
972+
// Format as YYYYMMDDTHHMMSSZ (e.g., 20250115T140000Z)
973+
const instanceDateStr = occurrenceDate
974+
.toISOString()
975+
.replace(/[-:]/g, "") // Remove dashes and colons
976+
.replace(/\.\d{3}/, ""); // Remove milliseconds
977+
978+
eventId = `${baseEventId}_${instanceDateStr}`;
979+
console.log(`Updating occurrence instance: ${eventId}`);
980+
}
981+
932982
// Get the auth token for this calendar
933983
const authToken = await this.get<string>(`auth_token_${calendarId}`);
934984

tools/outlook-calendar/src/graph-api.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -536,26 +536,30 @@ export function transformOutlookEvent(
536536
activity.recurrenceExdates = exdates;
537537
}
538538

539-
// Parse additional recurrence dates (not supported by Graph API)
539+
// Parse RDATEs (additional occurrence dates not in the recurrence rule)
540+
// Note: Microsoft Graph API doesn't support RDATE, so this will always be empty
540541
const rdates = parseOutlookRDates(event.recurrence);
541542
if (rdates.length > 0) {
542-
activity.recurrenceDates = rdates;
543+
activity.occurrences = rdates.map((rdate) => ({
544+
occurrence: rdate,
545+
start: rdate,
546+
}));
543547
}
544548
}
545549

546550
// Handle exception events (modifications to recurring event instances)
551+
// TODO: This should be updated to use the occurrences[] array pattern instead
552+
// For now, we store the exception info in metadata for future processing
547553
if (
548554
event.type === "exception" &&
549555
event.seriesMasterId &&
550556
event.originalStart
551557
) {
552558
// This is a modified instance of a recurring event
553-
const originalStartDate = new Date(event.originalStart);
554-
activity.occurrence = originalStartDate;
555-
// The seriesMasterId links this to the master recurring event
556-
// This will need to be matched to the master activity in the Plot system
559+
// Store the exception info in metadata
557560
if (activity.meta) {
558561
activity.meta.seriesMasterId = event.seriesMasterId;
562+
activity.meta.originalStartDate = new Date(event.originalStart).toISOString();
559563
}
560564
}
561565

0 commit comments

Comments
 (0)