Skip to content

Commit 2efa76d

Browse files
committed
Calendar deduping for issue 74 implemented
1 parent 0b22ac5 commit 2efa76d

4 files changed

Lines changed: 281 additions & 1 deletion

File tree

client/src/components/CalendarWidget.jsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ const CalendarWidget = ({
187187
const [calendarSettingsLoaded, setCalendarSettingsLoaded] = useState(false);
188188
const [showColorPicker, setShowColorPicker] = useState({ background: false, text: false });
189189
const [calendarSources, setCalendarSources] = useState([]);
190+
const [dedupEnabled, setDedupEnabled] = useState(false);
190191
const [showCalendarDialog, setShowCalendarDialog] = useState(false);
191192
const [editingCalendar, setEditingCalendar] = useState(null);
192193
const [calendarForm, setCalendarForm] = useState({
@@ -237,6 +238,7 @@ const CalendarWidget = ({
237238
fetchCalendarSources();
238239
fetchCalendarEvents();
239240
fetchSyncStatus();
241+
fetchDedupSetting();
240242
}, []);
241243

242244
// Auto-refresh functionality
@@ -403,6 +405,30 @@ const CalendarWidget = ({
403405
}
404406
};
405407

408+
const fetchDedupSetting = async () => {
409+
try {
410+
const response = await axios.get(`${API_BASE_URL}/api/settings`);
411+
setDedupEnabled(response.data?.CALENDAR_DEDUP_ENABLED === 'true');
412+
} catch (error) {
413+
console.error('Error fetching dedup setting:', error);
414+
}
415+
};
416+
417+
const handleToggleDedup = async (event) => {
418+
const enabled = event.target.checked;
419+
setDedupEnabled(enabled);
420+
try {
421+
await axios.post(`${API_BASE_URL}/api/settings`, {
422+
key: 'CALENDAR_DEDUP_ENABLED',
423+
value: enabled ? 'true' : 'false',
424+
});
425+
await fetchCalendarEvents();
426+
} catch (error) {
427+
console.error('Error saving dedup setting:', error);
428+
setDedupEnabled(!enabled); // revert on failure
429+
}
430+
};
431+
406432
const fetchSyncStatus = async () => {
407433
try {
408434
const response = await axios.get(`${API_BASE_URL}/api/calendar-sync/status`);
@@ -1965,6 +1991,17 @@ const CalendarWidget = ({
19651991

19661992
<Divider sx={{ my: 2 }} />
19671993

1994+
<FormControlLabel
1995+
control={<Switch checked={dedupEnabled} onChange={handleToggleDedup} />}
1996+
label="Merge duplicate events across calendars"
1997+
/>
1998+
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
1999+
When the same event appears on more than one calendar (even with slightly
2000+
different titles), show it only once.
2001+
</Typography>
2002+
2003+
<Divider sx={{ my: 2 }} />
2004+
19682005
<Typography variant="h6" sx={{ mb: 2 }}>Tab specific settings</Typography>
19692006

19702007
<Box sx={{ mb: 2 }}>

server/services/calendarSync.js

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const node_ical = require('node-ical');
44
const googleConnection = require('./googleConnection');
55
const googleCalendar = require('./googleCalendar');
66
const appleCalDAV = require('./appleCalDAV');
7+
const { dedupeCalendarEvents } = require('../utils/calendarDedup');
78

89
class CalendarSyncService {
910
constructor(db, decryptPassword) {
@@ -286,7 +287,7 @@ class CalendarSyncService {
286287

287288
const rows = this.db.prepare(query).all(...params);
288289

289-
return rows.map(row => {
290+
const events = rows.map(row => {
290291
const source = sourceMap.get(row.source_id);
291292
return {
292293
id: row.event_uid,
@@ -301,6 +302,22 @@ class CalendarSyncService {
301302
source_color: source?.color || '#6e44ff'
302303
};
303304
});
305+
306+
// Opt-in: merge the same event synced from multiple calendars (off by
307+
// default so no events silently disappear for existing installs).
308+
if (this.isDedupEnabled()) {
309+
return dedupeCalendarEvents(events);
310+
}
311+
return events;
312+
}
313+
314+
isDedupEnabled() {
315+
try {
316+
const row = this.db.prepare("SELECT value FROM settings WHERE key = 'CALENDAR_DEDUP_ENABLED'").get();
317+
return row?.value === 'true';
318+
} catch {
319+
return false;
320+
}
304321
}
305322

306323
getSyncStatus(sourceId) {

server/tests/calendarDedup.test.js

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
const test = require('node:test');
2+
const assert = require('node:assert/strict');
3+
const { dedupeCalendarEvents, normalizeTitle, titleSimilarity } = require('../utils/calendarDedup');
4+
5+
const BASE = new Date('2026-07-10T15:00:00.000Z').getTime();
6+
7+
function ev(id, sourceId, sourceName, title, startOffsetMin = 0, durationMin = 60) {
8+
const start = new Date(BASE + startOffsetMin * 60000);
9+
const end = new Date(start.getTime() + durationMin * 60000);
10+
return { id, source_id: sourceId, source_name: sourceName, title, start, end };
11+
}
12+
13+
test('normalizeTitle strips calendar prefix, punctuation, and case', () => {
14+
assert.equal(normalizeTitle('[Family] Soccer Practice!'), 'soccer practice');
15+
assert.equal(normalizeTitle('(Work) Stand-up'), 'stand up');
16+
assert.equal(normalizeTitle(null), '');
17+
});
18+
19+
test('titleSimilarity: containment and exact match score 1', () => {
20+
assert.equal(titleSimilarity('Soccer', 'Soccer practice'), 1);
21+
assert.equal(titleSimilarity('Dentist', 'dentist'), 1);
22+
assert.ok(titleSimilarity('Dentist', 'Soccer') < 0.3);
23+
});
24+
25+
test('merges the same event across two different sources', () => {
26+
const events = [
27+
ev('a1', 1, 'Family ICS', 'Soccer practice'),
28+
ev('a2', 2, 'Google', 'Soccer Practice - City Fields', 2),
29+
];
30+
const out = dedupeCalendarEvents(events);
31+
assert.equal(out.length, 1);
32+
assert.equal(out[0].source_id, 1, 'lowest source_id survives');
33+
assert.equal(out[0].title, 'Soccer practice');
34+
assert.deepEqual(out[0].merged_from, [{ source_id: 2, source_name: 'Google' }]);
35+
});
36+
37+
test('never merges two events from the same source', () => {
38+
const events = [
39+
ev('d1', 1, 'Family ICS', 'Dishes'),
40+
ev('d2', 1, 'Family ICS', 'Dishes'),
41+
];
42+
const out = dedupeCalendarEvents(events);
43+
assert.equal(out.length, 2);
44+
assert.ok(!out.some((e) => e.merged_from));
45+
});
46+
47+
test('does not merge same title at clearly different times', () => {
48+
const events = [
49+
ev('b1', 1, 'Family ICS', 'Dentist', 0),
50+
ev('c1', 2, 'Google', 'Dentist', 120), // 2 hours later
51+
];
52+
const out = dedupeCalendarEvents(events);
53+
assert.equal(out.length, 2);
54+
});
55+
56+
test('does not merge different events at the same time', () => {
57+
const events = [
58+
ev('x1', 1, 'Family ICS', 'Piano lesson'),
59+
ev('y1', 2, 'Google', 'Grocery run'),
60+
];
61+
const out = dedupeCalendarEvents(events);
62+
assert.equal(out.length, 2);
63+
});
64+
65+
test('merges a 3-source cluster into one survivor listing both others', () => {
66+
const events = [
67+
ev('t3', 3, 'Apple', 'Team meeting', 1),
68+
ev('t1', 1, 'Family ICS', 'Team meeting'),
69+
ev('t2', 2, 'Google', 'Team Meeting', 3),
70+
];
71+
const out = dedupeCalendarEvents(events);
72+
assert.equal(out.length, 1);
73+
assert.equal(out[0].source_id, 1);
74+
assert.deepEqual(
75+
out[0].merged_from.map((m) => m.source_id),
76+
[2, 3]
77+
);
78+
});
79+
80+
test('respects the time tolerance boundary', () => {
81+
const withinTol = [ev('w1', 1, 'A', 'Event'), ev('w2', 2, 'B', 'Event', 4)];
82+
assert.equal(dedupeCalendarEvents(withinTol).length, 1, '4 min apart merges');
83+
84+
const outsideTol = [ev('o1', 1, 'A', 'Event'), ev('o2', 2, 'B', 'Event', 10)];
85+
assert.equal(dedupeCalendarEvents(outsideTol).length, 2, '10 min apart does not');
86+
});
87+
88+
test('output stays sorted by start time and does not mutate input', () => {
89+
const events = [
90+
ev('late', 1, 'A', 'Late thing', 120),
91+
ev('early', 2, 'B', 'Early thing', 0),
92+
];
93+
const snapshot = JSON.stringify(events);
94+
const out = dedupeCalendarEvents(events);
95+
assert.equal(out[0].id, 'early');
96+
assert.equal(out[1].id, 'late');
97+
assert.equal(JSON.stringify(events), snapshot, 'input array/objects unchanged');
98+
});
99+
100+
test('passes through arrays of 0 or 1 event unchanged', () => {
101+
assert.deepEqual(dedupeCalendarEvents([]), []);
102+
const one = [ev('solo', 1, 'A', 'Solo')];
103+
assert.equal(dedupeCalendarEvents(one).length, 1);
104+
});

server/utils/calendarDedup.js

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Deduplicate calendar events that represent the same real-world event synced
2+
// from more than one calendar source (e.g. a shared event that lives on both a
3+
// family ICS feed and a personal Google calendar, sometimes with slightly
4+
// different titles). This is a pure, read-time merge — it never touches the
5+
// per-source cache, so editing/deleting still targets the surviving event's
6+
// real (source_id, id).
7+
8+
// Two events count as "the same time" when both start and end are within this
9+
// tolerance. Absorbs small timezone/DST/all-day offsets between sources.
10+
const DEFAULT_TIME_TOLERANCE_MS = 5 * 60 * 1000;
11+
12+
// Dice-coefficient title similarity above this counts as a match (0..1).
13+
const DEFAULT_SIMILARITY_THRESHOLD = 0.6;
14+
15+
// Lowercase, drop a leading "[Family] " / "(Work) " calendar prefix, replace
16+
// punctuation with spaces, collapse whitespace.
17+
function normalizeTitle(title) {
18+
if (typeof title !== 'string') return '';
19+
return title
20+
.toLowerCase()
21+
.replace(/^\s*[[(][^\])]*[\])]\s*/, '')
22+
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
23+
.replace(/\s+/g, ' ')
24+
.trim();
25+
}
26+
27+
function bigramCounts(str) {
28+
const counts = new Map();
29+
for (let i = 0; i < str.length - 1; i++) {
30+
const bg = str.slice(i, i + 2);
31+
counts.set(bg, (counts.get(bg) || 0) + 1);
32+
}
33+
return counts;
34+
}
35+
36+
// Sørensen–Dice coefficient over character bigrams. Robust to minor edits and
37+
// word reordering; returns 1 for equality or when one title contains the other
38+
// ("Soccer" vs "Soccer practice").
39+
function titleSimilarity(a, b) {
40+
const na = normalizeTitle(a);
41+
const nb = normalizeTitle(b);
42+
if (!na && !nb) return 1;
43+
if (!na || !nb) return 0;
44+
if (na === nb) return 1;
45+
if (na.includes(nb) || nb.includes(na)) return 1;
46+
if (na.length < 2 || nb.length < 2) return 0;
47+
48+
const A = bigramCounts(na);
49+
const B = bigramCounts(nb);
50+
let overlap = 0;
51+
let sizeA = 0;
52+
let sizeB = 0;
53+
for (const c of A.values()) sizeA += c;
54+
for (const c of B.values()) sizeB += c;
55+
for (const [bg, countA] of A) {
56+
overlap += Math.min(countA, B.get(bg) || 0);
57+
}
58+
return (2 * overlap) / (sizeA + sizeB);
59+
}
60+
61+
function timesMatch(a, b, toleranceMs) {
62+
const startDiff = Math.abs(new Date(a.start).getTime() - new Date(b.start).getTime());
63+
const endDiff = Math.abs(new Date(a.end).getTime() - new Date(b.end).getTime());
64+
return startDiff <= toleranceMs && endDiff <= toleranceMs;
65+
}
66+
67+
// Merge duplicate events. Returns a new array (input is not mutated). Events
68+
// only merge across *different* sources; the survivor is the copy from the
69+
// lowest source_id, with a `merged_from: [{source_id, source_name}]` list of
70+
// the sources it absorbed. Output is sorted by start time (source_id tiebreak).
71+
function dedupeCalendarEvents(events, options = {}) {
72+
if (!Array.isArray(events) || events.length < 2) {
73+
return Array.isArray(events) ? events.slice() : events;
74+
}
75+
76+
const toleranceMs = options.toleranceMs ?? DEFAULT_TIME_TOLERANCE_MS;
77+
const threshold = options.similarityThreshold ?? DEFAULT_SIMILARITY_THRESHOLD;
78+
79+
const clusters = [];
80+
for (const event of events) {
81+
let cluster = null;
82+
for (const c of clusters) {
83+
// Never merge two events from the same source.
84+
if (c.sourceIds.has(event.source_id)) continue;
85+
if (!timesMatch(c.survivor, event, toleranceMs)) continue;
86+
if (titleSimilarity(c.survivor.title, event.title) < threshold) continue;
87+
cluster = c;
88+
break;
89+
}
90+
91+
if (!cluster) {
92+
clusters.push({ survivor: event, absorbed: [], sourceIds: new Set([event.source_id]) });
93+
continue;
94+
}
95+
96+
cluster.sourceIds.add(event.source_id);
97+
// Deterministic survivor: lowest source_id wins.
98+
if (event.source_id < cluster.survivor.source_id) {
99+
cluster.absorbed.push(cluster.survivor);
100+
cluster.survivor = event;
101+
} else {
102+
cluster.absorbed.push(event);
103+
}
104+
}
105+
106+
const output = clusters.map((c) => {
107+
const survivor = { ...c.survivor };
108+
if (c.absorbed.length > 0) {
109+
survivor.merged_from = c.absorbed
110+
.map((e) => ({ source_id: e.source_id, source_name: e.source_name }))
111+
.sort((x, y) => x.source_id - y.source_id);
112+
}
113+
return survivor;
114+
});
115+
116+
output.sort(
117+
(a, b) => new Date(a.start).getTime() - new Date(b.start).getTime() || a.source_id - b.source_id
118+
);
119+
return output;
120+
}
121+
122+
module.exports = { dedupeCalendarEvents, normalizeTitle, titleSimilarity };

0 commit comments

Comments
 (0)