DEV Community

Anthony Cole
Anthony Cole

Posted on

Building a Unified Calendar API: Lessons from Aggregating Google Calendar, CalDAV, and Jira

How I built a calendar aggregator with .NET 9, React 19, and learned about multi-tenant architecture the hard way

The Problem: Calendar Chaos

If you're a software engineer in 2025, you probably have:

  • A work Google Calendar (meetings, standup, 1:1s)
  • A personal CalDAV calendar (Nextcloud, iCloud, etc.)
  • Jira tickets you need to schedule time for
  • A mental breakdown every Monday morning

I got tired of switching between three browser tabs to see if I had time to actually write code. So I built UnifyTime — a calendar aggregator that pulls everything into one view and lets you schedule Jira tickets directly onto your calendar.

This is the technical deep-dive on how it works.

Architecture Overview

Tech Stack:

  • Backend: .NET 9 Web API, Entity Framework Core, PostgreSQL
  • Frontend: React 19, Vite, TanStack Query (React Query)
  • Monorepo: npm workspaces for frontend, shared .NET solution

The core challenge was multi-source aggregation: how do you merge calendars from completely different APIs (Google's OAuth2, CalDAV's WebDAV protocol, Jira's REST API) into a single unified view while keeping everything in sync?

Challenge #1: Multi-Tenant Calendar Sources

The Schema

I needed a database schema that could handle:

  • Multiple users
  • Multiple calendar sources per user (Google, CalDAV, etc.)
  • Multiple calendars per source (Google lets you have 10+ calendars)
  • Thousands of events across all calendars

Here's what I landed on:

// Simplified models from packages/dotnet/UnifyTime.Shared/Models/

public class User
{
    public Guid Id { get; set; }
    public string Email { get; set; }
    public string TimeZone { get; set; } // e.g., "America/New_York"
}

public class CalendarSource
{
    public Guid Id { get; set; }
    public Guid UserId { get; set; }
    public SourceType Type { get; set; } // Google, CalDAV, etc.

    // Encrypted OAuth tokens or credentials
    public string EncryptedAccessToken { get; set; }
    public string EncryptedRefreshToken { get; set; }
}

public class Calendar
{
    public Guid Id { get; set; }
    public Guid SourceId { get; set; }
    public string Name { get; set; }
    public string ExternalCalendarId { get; set; } // Google's ID or CalDAV URL
}

public class CalendarEvent
{
    public Guid Id { get; set; }
    public Guid CalendarId { get; set; }
    public string ExternalEventId { get; set; } // For bi-directional sync
    public string Title { get; set; }
    public DateTime StartTime { get; set; }
    public DateTime EndTime { get; set; }

    // Link to tasks
    public Guid? ScheduledTaskId { get; set; }
    public InboxTask? ScheduledTask { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Key Decision: Store all events in one table (CalendarEvent), not separate tables per source. This makes querying simple:

// From EventsController.cs
var events = await _context.CalendarEvents
    .Include(e => e.Calendar)
    .ThenInclude(c => c.Source)
    .Where(e => e.Calendar.Source.UserId == userId
             && e.StartTime >= startDate
             && e.EndTime <= endDate)
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

One query gets all events across all sources for a date range. EF Core handles the joins.

Challenge #2: Secure Token Storage

Google OAuth gives you access tokens and refresh tokens. CalDAV needs passwords. Jira needs API keys. You cannot store these in plaintext.

The Solution: ASP.NET Data Protection API

.NET has a built-in encryption system designed for exactly this:

// From GoogleCalendarService.cs (simplified)
public class GoogleCalendarService : ICalendarSyncService
{
    private readonly IDataProtector _protector;

    public GoogleCalendarService(IDataProtectionProvider provider)
    {
        // Create a purpose-specific protector
        _protector = provider.CreateProtector("GoogleCalendar.Tokens");
    }

    public async Task SaveTokensAsync(CalendarSource source, string accessToken)
    {
        // Encrypt before saving
        source.EncryptedAccessToken = _protector.Protect(accessToken);
        await _context.SaveChangesAsync();
    }

    private string GetAccessToken(CalendarSource source)
    {
        // Decrypt when needed
        return _protector.Unprotect(source.EncryptedAccessToken);
    }
}
Enter fullscreen mode Exit fullscreen mode

Why this is great:

  • Tokens are encrypted at rest in PostgreSQL
  • Keys are stored in Redis (production) or filesystem (dev)
  • If someone steals your database dump, they can't read the tokens without the encryption keys
  • Built into .NET, no third-party libraries

Challenge #3: Bi-Directional Sync

When a user edits an event in UnifyTime, it needs to update in Google Calendar. When they edit in Google, it needs to sync back to UnifyTime. This is deceptively hard.

The ExternalEventId Pattern

Every event stores its source system's ID:

public class CalendarEvent
{
    public Guid Id { get; set; }                    // Our internal ID
    public string ExternalEventId { get; set; }      // Google's ID or CalDAV UID
}
Enter fullscreen mode Exit fullscreen mode

When syncing:

// From GoogleCalendarService.cs (pseudocode)
public async Task SyncEventsAsync(CalendarSource source)
{
    var googleEvents = await FetchFromGoogle(source);

    foreach (var googleEvent in googleEvents)
    {
        var existingEvent = await _context.CalendarEvents
            .FirstOrDefaultAsync(e => e.ExternalEventId == googleEvent.Id);

        if (existingEvent == null)
        {
            // New event from Google → create in our DB
            _context.CalendarEvents.Add(MapToCalendarEvent(googleEvent));
        }
        else if (existingEvent.UpdatedAt < googleEvent.Updated)
        {
            // Google version is newer → update our DB
            UpdateFromGoogle(existingEvent, googleEvent);
        }
    }

    await _context.SaveChangesAsync();
}
Enter fullscreen mode Exit fullscreen mode

Gotcha: You need to track UpdatedAt timestamps and handle conflicts. I chose "last write wins" for simplicity, but you could build a more sophisticated merge strategy.

Challenge #4: Event-Task Linking (The Jira Magic)

The killer feature: search for a Jira ticket, click "Schedule", and it creates a calendar event that's linked to the ticket. When you complete the event, it logs work back to Jira.

The Schema

public class InboxTask
{
    public Guid Id { get; set; }
    public string Title { get; set; }
    public int EstimatedMinutes { get; set; }

    // Link to calendar event
    public Guid? ScheduledEventId { get; set; }
    public CalendarEvent? ScheduledEvent { get; set; }
}

public class EventExternalReference
{
    public Guid Id { get; set; }
    public Guid EventId { get; set; }
    public ExternalReferenceType Type { get; set; } // Jira, GitHub, etc.
    public string ExternalId { get; set; }           // e.g., "PROJ-123"
    public string Url { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

When you schedule a Jira ticket:

// From JiraController.cs
[HttpPost("schedule")]
public async Task<IActionResult> ScheduleTicket(ScheduleJiraTicketRequest request)
{
    var userId = GetUserId();

    // Create calendar event
    var calendarEvent = new CalendarEvent
    {
        Title = $"[JIRA] {request.TicketSummary}",
        StartTime = request.StartTime,
        EndTime = request.EndTime,
        CalendarId = request.CalendarId
    };

    // Link to Jira
    var externalRef = new EventExternalReference
    {
        EventId = calendarEvent.Id,
        Type = ExternalReferenceType.Jira,
        ExternalId = request.TicketKey, // e.g., "PROJ-123"
        Url = $"https://yourcompany.atlassian.net/browse/{request.TicketKey}"
    };

    _context.CalendarEvents.Add(calendarEvent);
    _context.EventExternalReferences.Add(externalRef);
    await _context.SaveChangesAsync();

    return Ok();
}
Enter fullscreen mode Exit fullscreen mode

When you complete the event, it calls JiraService.LogWorkAsync() to record time in Jira.

Challenge #5: Frontend State Management

The frontend needs to:

  • Display events from multiple sources in one calendar view
  • Handle drag-and-drop rescheduling
  • Optimistically update the UI before server confirmation
  • Stay in sync with the backend

TanStack Query to the Rescue

I used React Query for all server state:

// From apps/web/src/hooks/useEvents.ts
export function useEvents(startDate: Date, endDate: Date) {
  return useQuery({
    queryKey: ['events', startDate.toISOString(), endDate.toISOString()],
    queryFn: () => eventsApi.getEvents(startDate, endDate),
    staleTime: 1000 * 60 * 5, // 5 minutes
  });
}

// In CalendarView.tsx
const { data: events, isLoading } = useEvents(rangeStart, rangeEnd);
Enter fullscreen mode Exit fullscreen mode

Why this is great:

  • Automatic caching by date range
  • Background refetching
  • Built-in loading/error states
  • Optimistic updates with useMutation

For drag-and-drop:

const updateEventMutation = useMutation({
  mutationFn: (data: UpdateEventRequest) => eventsApi.updateEvent(data),
  onMutate: async (variables) => {
    // Optimistically update the UI
    await queryClient.cancelQueries({ queryKey: ['events'] });
    const previous = queryClient.getQueryData(['events']);

    queryClient.setQueryData(['events'], (old) => {
      // Update the event in the cache immediately
      return updateEventInCache(old, variables);
    });

    return { previous };
  },
  onError: (err, variables, context) => {
    // Rollback on error
    queryClient.setQueryData(['events'], context.previous);
  },
});
Enter fullscreen mode Exit fullscreen mode

The calendar feels instant because the UI updates before the server responds.

The Monorepo Setup

I used npm workspaces to keep frontend and backend in one repo:

// Root package.json
{
  "name": "unifytime",
  "workspaces": [
    "apps/web",
    "packages/typescript/shared-types"
  ],
  "scripts": {
    "dev": "npm run dev --workspaces",
    "build": "npm run build --workspaces"
  }
}
Enter fullscreen mode Exit fullscreen mode

The .NET solution (UnifyTime.sln) ties together:

  • apps/api/UnifyTime.Api (the Web API)
  • apps/api/UnifyTime.Api.Tests (xUnit tests)
  • packages/dotnet/UnifyTime.Shared (shared models, services, EF context)

Why monorepo:

  • Share types between frontend and backend (future: generate TypeScript from C# models)
  • One git clone to get the whole app
  • Atomic commits across API and UI changes

Lessons Learned

  1. Start with the schema: I refactored the database 3 times before settling on this design. Draw the entity relationships first.

  2. Encrypt everything: Use Data Protection API for tokens. It's built-in and battle-tested.

  3. External IDs are critical: You need ExternalEventId for sync. Don't try to use your internal IDs.

  4. React Query scales: It handled caching, refetching, and optimistic updates better than I could with useState.

  5. Multi-tenancy from day one: Filter by userId in every query. Use [Authorize] on every controller. One slip-up and you leak data.

  6. DTOs prevent over-posting: Never accept EF entities directly in API endpoints. Use request/response DTOs.

What's Next

I'm currently working on:

  • Auto-scheduling: Algorithm to find open slots and schedule tasks based on priority
  • Conflict detection: Warn before double-booking
  • GitHub integration: Link PRs to calendar blocks
  • Desktop app: Tauri wrapper for offline access

Tech Stack Summary:

  • Backend: .NET 9, EF Core, PostgreSQL, JWT auth
  • Frontend: React 19, Vite, TanStack Query, Radix UI
  • Integrations: Google Calendar API, CalDAV, Jira REST API

Have you tackled multi-source data aggregation in your projects? What patterns did you use? Drop a comment below!

Top comments (0)