Kvika Team·· 8 min read

Bidirectional Calendar Sync Without Losing User Edits

modifiedLocally, CONFLICT status, and scoped delete reconciliation — how we sync Google and Outlook calendars while letting users drag events and an algorithm reschedule focus blocks.

calendarsyncengineeringgoogle-calendarmicrosoft-outlook

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:

typescript
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
}
Detection without auto-merge is enough for v1. Surface CONFLICT in the UI and let the user pick a winner. Silent merge picks wrong half the time.

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:

typescript
const where = {
  userId,
  source,
  modifiedLocally: false,
  AND: [
    { startTime: { gte: startDate } },
    { endTime: { lte: endDate } },
  ],
}

// delete DB events in window whose sourceId ∉ externalEventIds

This 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.

Kvika Team

Kvika unifies your calendar, email, and tasks across Google and Microsoft. Join the beta at kvika.work/waitlist.