@@ -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
0 commit comments