@@ -46,6 +46,19 @@ function parseEventTitle(title: string): { name: string; queue?: string } {
4646 return { name : title . trim ( ) } ;
4747}
4848
49+ // Count non-null/non-undefined fields in a ParsedEvent for deduplication scoring
50+ function countNonNullFields ( event : ParsedEvent ) : number {
51+ let score = 0 ;
52+ if ( event . title ) score ++ ;
53+ if ( event . description ) score ++ ;
54+ if ( event . location ) score ++ ;
55+ if ( event . queue_name ) score ++ ;
56+ if ( event . organizer_name ) score ++ ;
57+ if ( event . start_time ) score ++ ;
58+ if ( event . end_time ) score ++ ;
59+ return score ;
60+ }
61+
4962// Check if a UID represents a recurring occurrence (has _YYYYMMDDTHHMMSS suffix)
5063function isRecurringOccurrence ( uid : string ) : boolean {
5164 return / _ \d { 8 } T \d { 6 } $ / . test ( uid ) ;
@@ -242,7 +255,7 @@ function convertToUTC(
242255 // Calculate total seconds difference in the timezone's local time representation
243256 const targetTotalSeconds = hour * 3600 + minute * 60 + second ;
244257 const tzTotalSeconds = tzHour * 3600 + tzMinute * 60 + tzSecond ;
245- let secondsDiff = targetTotalSeconds - tzTotalSeconds ;
258+ const secondsDiff = targetTotalSeconds - tzTotalSeconds ;
246259
247260 // Account for day differences (which can cause hour rollover)
248261 const targetDate = new Date ( Date . UTC ( year , month , day ) ) ;
@@ -825,37 +838,54 @@ function parseICS(icsContent: string, expandUntil: Date, defaultTimezone: string
825838 modifiedDates . set ( `${ baseUid } _${ dateKey } ` , mod ) ;
826839 }
827840
828- // Replace or remove generated occurrences that have been modified
829- return expandedEvents . filter ( ( event ) => {
841+ // Filter out generated occurrences that have been modified
842+ const filteredEvents = expandedEvents . filter ( ( event ) => {
830843 if ( event . recurrenceId ) return true ; // Keep modified occurrences
831844 const dateKey = event . dtstart . toISOString ( ) . split ( "T" ) [ 0 ] ;
832- // Check if this generated occurrence should be replaced by a modified one
833- // The modified occurrence has the original UID, our generated has UID_datetime
834845 const originalUid = getBaseUid ( event . uid ) ;
835846 const modKey = `${ originalUid } _${ dateKey } ` ;
836847 return ! modifiedDates . has ( modKey ) ;
837848 } ) ;
849+
850+ // Normalize modified occurrence UIDs to match generated UID format
851+ // This ensures consistent UIDs and prevents base UID vs datetime UID mismatches
852+ return filteredEvents . map ( ( event ) => {
853+ if ( event . recurrenceId ) {
854+ const dateStr = event . recurrenceId . toISOString ( ) . replace ( / [ - : ] / g, "" ) . split ( "." ) [ 0 ] ;
855+ return { ...event , uid : `${ event . uid } _${ dateStr } ` } ;
856+ }
857+ return event ;
858+ } ) ;
838859 }
839860
840861 return expandedEvents ;
841862}
842863
843864// Convert ICS event to ParsedEvent
844865// Dates are converted to ISO strings (UTC) which Postgres will store as timestamptz
866+ // All text fields are trimmed to ensure consistent comparisons and avoid false change detection
845867function convertToCalendarEvent ( event : ICSEvent ) : ParsedEvent {
846868 const { name, queue } = parseEventTitle ( event . summary ) ;
847869
870+ // Normalize location: use ICS LOCATION if present, otherwise use queue_name from title
871+ // This ensures consistent data even if ICS sometimes includes/excludes LOCATION field
872+ const location = event . location ?. trim ( ) || queue || undefined ;
873+
874+ // Trim all text fields to avoid whitespace-induced false changes
875+ const title = event . summary ?. trim ( ) ;
876+ const description = event . description ?. trim ( ) || undefined ;
877+
848878 return {
849879 uid : event . uid ,
850- title : event . summary ,
851- description : event . description ,
880+ title : title ,
881+ description : description ,
852882 // toISOString() produces UTC ISO strings (e.g., "2024-12-10T10:00:00.000Z")
853883 // Postgres timestamptz will parse these correctly and store as UTC internally
854884 start_time : event . dtstart . toISOString ( ) ,
855885 end_time : event . dtend . toISOString ( ) ,
856- location : event . location ,
857- queue_name : queue ,
858- organizer_name : name ,
886+ location : location ,
887+ queue_name : queue , // Already trimmed by parseEventTitle
888+ organizer_name : name , // Already trimmed by parseEventTitle
859889 raw_ics_data : {
860890 uid : event . uid ,
861891 summary : event . summary ,
@@ -965,7 +995,31 @@ async function syncCalendar(
965995 // Normalize timezone to IANA format (class timezone should already be IANA, but normalize to be safe)
966996 const defaultTimezone = normalizeTimezone ( classData . time_zone || "UTC" ) ;
967997 const icsEvents = parseICS ( content , expandUntil , defaultTimezone ) ;
968- const parsedEvents = icsEvents . map ( convertToCalendarEvent ) ;
998+ const parsedEventsRaw = icsEvents . map ( convertToCalendarEvent ) ;
999+
1000+ // Deduplicate events by UID - if same UID appears multiple times, keep the one with most data
1001+ // This prevents duplicate updates when ICS has both base events and modified occurrences
1002+ const eventsByUid = new Map < string , ParsedEvent > ( ) ;
1003+ for ( const event of parsedEventsRaw ) {
1004+ const existing = eventsByUid . get ( event . uid ) ;
1005+ if ( ! existing ) {
1006+ eventsByUid . set ( event . uid , event ) ;
1007+ } else {
1008+ // Keep the event with more non-null fields (prefer event with location, description, etc.)
1009+ const existingScore = countNonNullFields ( existing ) ;
1010+ const newScore = countNonNullFields ( event ) ;
1011+ if ( newScore > existingScore ) {
1012+ eventsByUid . set ( event . uid , event ) ;
1013+ }
1014+ }
1015+ }
1016+ const parsedEvents = Array . from ( eventsByUid . values ( ) ) ;
1017+
1018+ if ( parsedEventsRaw . length !== parsedEvents . length ) {
1019+ console . log (
1020+ `[syncCalendar] Deduplicated ${ parsedEventsRaw . length } -> ${ parsedEvents . length } events (removed ${ parsedEventsRaw . length - parsedEvents . length } duplicates)`
1021+ ) ;
1022+ }
9691023
9701024 console . log ( `[syncCalendar] Parsed ${ parsedEvents . length } events from ICS (including expanded recurrences)` ) ;
9711025
0 commit comments