A unified calendar is not read-only. Users drag focus blocks. A rescheduling algorithm moves manageable events to open gaps. Changes need to push back to Google or Microsoft. Meanwhile, pull sync runs on a schedule and imports whatever changed externally.
Without explicit rules, pull sync clobbers local edits. Or local edits fight external truth silently. We do not have CRDTs or operational transforms for calendar events — we have flags, conflict markers, and a delete reconciliation function with a scary footgun we almost shipped. This post explains the patterns that kept us honest.
Pull sync meets local edits
Our sync loop fetches events from Google and Microsoft into a normalised CalendarEvent table. Each row has a composite unique key: userId + source + sourceId. Upserts are idempotent — overlapping cron jobs and manual refreshes do not create duplicate meetings.
When the user reschedules an event in Kvika, we PATCH the row, set modifiedLocally: true, and mark syncStatus: PENDING. A separate push job writes the change to the external calendar. Until push succeeds, pull sync must not blindly overwrite title, start, or end times.
The modifiedLocally flag
On pull, if modifiedLocally is false, we compare incoming provider data with the stored row and update when title, start, end, or location changed. If nothing changed, we touch lastSyncedAt only.
If modifiedLocally is true, we skip the normal update path. Instead we check whether the external calendar also changed times — which means two editors disagreed:
if (existing.modifiedLocally) {
const externalTimeChanged =
existing.startTime.getTime() !== eventData.startTime.getTime() ||
existing.endTime.getTime() !== eventData.endTime.getTime()
if (externalTimeChanged) {
await prisma.calendarEvent.update({
where: { id: existing.id },
data: {
syncStatus: SyncStatus.CONFLICT,
syncError: 'Event was modified both locally and externally',
},
})
}
// do not overwrite local times
}Detecting conflicts without CRDTs
Full bidirectional sync with conflict resolution is a research topic. Product-grade v1 needs three things: do not overwrite local edits on pull, detect when both sides changed the same field, and show humans a choice.
We compare timestamps on start/end only for conflict detection — not title or attendees yet. Shallow comparison misses some drift but avoids noise from cosmetic external edits. Push failures set syncStatus: ERROR with the provider error string so support can distinguish "conflict" from "token expired."
Scoped delete reconciliation
After upserting fetched events, we reconcile deletions: remove DB rows whose sourceId no longer appears in the provider response. The naive version deletes any missing ID. That deleted next month's meetings when this week's sync window did not include them.
cleanupDeletedEvents scopes candidates to the fetched date range and skips rows with modifiedLocally: true:
const where = {
userId,
source,
modifiedLocally: false,
AND: [
{ startTime: { gte: startDate } },
{ endTime: { lte: endDate } },
],
}
// delete DB events in window whose sourceId ∉ externalEventIdsThis function exists because we thought about the scary case before production, not after. If your sync fetches a sliding window, your delete logic must use the same window.
Classification drives what can move
Not every event should be draggable by an algorithm. We classify on import: external attendees → MEETING; focus keywords in title → FOCUS_TIME; task keywords → TASK; else PERSONAL.
isEventManageable returns false for meetings with anyone other than the user on the guest list. Focus and task blocks default to manageable. The rescheduling algorithm only PATCHes events where isManaged is true and the user opted in.
Classification quality downstream affects analytics too — focus hours KPIs read eventType from these same rows. Bad heuristics mean bad dashboards, not just bad auto-scheduling.
See also: incremental calendar sync patterns · which events the algorithm may move. Join the Kvika beta.