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; }
}
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();
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);
}
}
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
}
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();
}
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; }
}
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();
}
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);
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);
},
});
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"
}
}
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 cloneto get the whole app - Atomic commits across API and UI changes
Lessons Learned
Start with the schema: I refactored the database 3 times before settling on this design. Draw the entity relationships first.
Encrypt everything: Use Data Protection API for tokens. It's built-in and battle-tested.
External IDs are critical: You need
ExternalEventIdfor sync. Don't try to use your internal IDs.React Query scales: It handled caching, refetching, and optimistic updates better than I could with useState.
Multi-tenancy from day one: Filter by
userIdin every query. Use[Authorize]on every controller. One slip-up and you leak data.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)