Skip to content

Commit c84100c

Browse files
authored
Reduce calendar realtime message spam (#619)
1 parent 14a4890 commit c84100c

File tree

2 files changed

+865
-11
lines changed

2 files changed

+865
-11
lines changed

supabase/functions/calendar-sync/index.ts

Lines changed: 65 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -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)
5063
function 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
845867
function 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

Comments
 (0)