Bi-directional and one-way synchronization of Google Calendar events between two different Google accounts. Web-based multi-tenant SaaS application with React + Material UI frontend and FastAPI backend.
✅ Fully tested and stable - 128 passing tests (101 backend + 27 frontend) with comprehensive E2E coverage (6 test scripts).
📋 See CHANGELOG.md for detailed version history and release notes
🚀 New to Calendar Sync? Start with INSTALL.md for a beginner-friendly setup guide!
- Multi-tenant SaaS with Google OAuth-only authentication
- Registration and login via Google OAuth (registered Google account becomes Account 1)
- Web OAuth flow for connecting Account 2
- Calendar selection UI for choosing which calendars to sync
- Bi-directional sync: Events sync both ways between selected calendars
- One-way sync: Traditional source → destination syncing
- Each synced event stores
source_idand bidirectional metadata in extended properties - Idempotent sync mechanism - unlimited re-runs without duplicates
- Sync triggered manually via web dashboard (or scheduled via Cloud Scheduler in production)
- Only syncs future events (default: now → 90 days)
Tech Stack:
- Backend: Python 3.12, FastAPI, SQLAlchemy, Alembic, PostgreSQL
- Frontend: React 18, TypeScript, Material UI 5, Vite
- Infrastructure: Terraform (GCP Cloud Run, Cloud SQL, Secret Manager)
- OAuth: Web Application flow (migrated from Desktop OOB)
Database Schema:
users- User accounts (email from Google OAuth, no passwords)oauth_tokens- Encrypted Google OAuth tokens (Fernet encryption)calendars- Cached calendar lists from Googlesync_configs- User sync configurationssync_logs- Sync history and statisticsevent_mappings- Bidirectional event tracking (Story 3)
-
Local Development:
- Python 3.12+
- Node.js 18+
- Docker and Docker Compose
- Google Cloud project with OAuth client
-
Production Deployment:
- Terraform
- GCP project with billing enabled
# Clone repository
git clone <repository-url>
cd cal-sync
# Copy environment template and add your secrets
cp .env.example .env.local
# Edit .env.local with your OAuth credentials (see step-by-step guide below)
# Build and start all services (unified container on port 8033)
docker build -t cal-sync:latest .
docker compose up -d
# Access application
# Web App: http://localhost:8033
# API Docs: http://localhost:8033/docsPort Configuration:
- Docker deployment (default): Port 8033 - unified frontend + backend
- Local development: Port 8000 (backend), Port 3033 (frontend) - see Local Development Setup below
To change the Docker port: Edit .env file and update EXTERNAL_PORT, API_URL, FRONTEND_URL (all grouped together with instructions).
-
Create OAuth consent screen:
- User Type: External
- App name: Calendar Sync
- Scopes: Add
https://www.googleapis.com/auth/calendar - Add test users (your Google accounts)
-
Create OAuth client ID:
- Application type: Web application
- Authorized redirect URIs:
- Docker deployment:
http://localhost:8033/api/oauth/callback - Local development:
http://localhost:8000/api/oauth/callback - Production:
https://yourdomain.com/api/oauth/callback
- Docker deployment:
- Download JSON credentials
Note: If you change the Docker port (via
EXTERNAL_PORTin.env), update the redirect URI to match.
For Docker deployment: Copy .env.example to .env.local:
cp .env.example .env.local
# Edit .env.local with your secretsThe .env file (committed) has defaults for Docker (port 8033). Add your secrets to .env.local:
# From OAuth client JSON
OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com
OAUTH_CLIENT_SECRET=your-client-secret
# Generate secrets
JWT_SECRET=$(openssl rand -base64 32)
ENCRYPTION_KEY=$(openssl rand -base64 32)For local development: Create .env.local with local overrides:
# Secrets (same as above)
OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com
OAUTH_CLIENT_SECRET=your-client-secret
JWT_SECRET=$(openssl rand -base64 32)
ENCRYPTION_KEY=$(openssl rand -base64 32)
# Local dev overrides (separate backend/frontend)
DATABASE_URL=postgresql://postgres:dev@localhost:5433/calsync
API_URL=http://localhost:8000
FRONTEND_URL=http://localhost:3033Frontend dev server also needs frontend/.env:
# For local development (backend on port 8000)
VITE_API_URL=http://localhost:8000/apiOption A: One-command launch (recommended):
./dev.shThis script automatically:
- ✅ Checks prerequisites (Docker, Python, Node.js)
- ✅ Starts PostgreSQL in Docker
- ✅ Creates Python venv and installs dependencies
- ✅ Runs database migrations
- ✅ Installs frontend dependencies
- ✅ Starts backend (port 8000) with hot-reload
- ✅ Starts frontend (port 3033) with hot-reload
- ✅ Shows combined logs
- ✅ Handles Ctrl+C to stop all services
Option B: Manual setup:
Click to expand manual setup instructions
docker-compose up -d dbcd backend
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
# Run migrations
alembic upgrade head
# Start backend server
uvicorn app.main:app --reload --port 8000Backend runs at http://localhost:8000
cd frontend
npm install
# Create .env for local dev
echo "VITE_API_URL=http://localhost:8000/api" > .env
npm run devFrontend runs at http://localhost:3033
- Open http://localhost:3033
- Sign in with Google (registration happens automatically)
- Your Google account is automatically connected as the source account
- Connect Destination Google account (OAuth flow)
- Select calendars and create sync configuration
- Trigger manual sync and view detailed results
- View sync history with complete audit trail
-
User Registration/Login
- Navigate to http://localhost:3033/login
- Click "Sign in with Google" → Google OAuth flow
- Your Google account is automatically registered and connected as the source account
- JWT token is stored for subsequent API requests
-
Connect Destination Google Account
- Dashboard shows OAuth status cards
- Source account is already connected (from registration)
- Click "Connect Destination Account" → Google OAuth flow
- Both accounts now show connected with email addresses
-
Create Sync Configuration
- Select source calendar from dropdown (shows all accessible calendars)
- Select destination calendar from dropdown
- Set sync lookahead window (default: 90 days)
- Click "Create Sync Configuration"
- Configuration appears in "Active Sync Configurations" section
-
Manage Syncs
- Trigger Manual Sync: Click "Trigger Sync Now" button
- View Results: See detailed feedback (events created/updated/deleted)
- View History: Click "View History" to see complete audit trail
- Delete Config: Remove sync configuration with confirmation
- Refresh: Update configuration list to see latest sync times
Backend API documentation: http://localhost:8033/docs (FastAPI auto-generated)
Authentication:
GET /api/auth/me- Get current user
OAuth:
GET /api/oauth/start/{account_type}- Initiate OAuth flow (account_type: "register", "source", or "destination")GET /api/oauth/callback- OAuth callback handler (creates user + source token for registration)GET /api/oauth/status- Check connection status
Calendars:
GET /api/calendars/{account_type}/list- List available calendarsPOST /api/calendars/{account_type}/events/create- Create event (for E2E testing)POST /api/calendars/{account_type}/events/update- Update event (for E2E testing)POST /api/calendars/{account_type}/events/delete- Delete event (for E2E testing)POST /api/calendars/{account_type}/events/list- List events with filters (for E2E testing)
Sync:
POST /api/sync/config- Create sync configuration (supports bi-directional)GET /api/sync/config- List user's sync configsDELETE /api/sync/config/{config_id}- Delete sync configurationPOST /api/sync/trigger/{config_id}- Trigger manual sync (supports trigger_both_directions parameter)GET /api/sync/logs/{config_id}- View sync history
cal-sync/
├── backend/
│ ├── app/
│ │ ├── api/ # API endpoints
│ │ │ ├── auth.py # User authentication
│ │ │ ├── oauth.py # Web OAuth flow
│ │ │ ├── calendars.py # Calendar selection
│ │ │ └── sync.py # Sync operations
│ │ ├── models/ # SQLAlchemy models
│ │ │ ├── user.py
│ │ │ ├── oauth_token.py
│ │ │ ├── sync_config.py
│ │ │ ├── sync_log.py
│ │ │ └── event_mapping.py # Story 3
│ │ ├── core/
│ │ │ ├── security.py # JWT + encryption
│ │ │ └── sync_engine.py # Refactored sync.py logic
│ │ ├── migrations/ # Alembic migrations
│ │ ├── config.py # Settings management
│ │ ├── database.py # SQLAlchemy setup
│ │ └── main.py # FastAPI app
│ ├── requirements.txt
│ ├── Dockerfile
│ └── alembic.ini
├── frontend/
│ ├── src/
│ │ ├── pages/
│ │ │ ├── Login.tsx
│ │ │ └── Dashboard.tsx
│ │ ├── components/
│ │ │ ├── CalendarSelector.tsx # Calendar dropdown
│ │ │ ├── SyncConfigForm.tsx # Sync config creation
│ │ │ └── SyncHistoryDialog.tsx # Sync history viewer
│ │ ├── context/
│ │ │ └── AuthContext.tsx
│ │ ├── services/
│ │ │ └── api.ts # Axios client
│ │ ├── constants/
│ │ │ └── colors.ts # Shared color palette constants
│ │ ├── theme/
│ │ │ └── theme.ts # Material UI theme
│ │ ├── App.tsx
│ │ ├── main.tsx
│ │ └── vite-env.d.ts # TypeScript declarations
│ ├── package.json
│ ├── vite.config.ts
│ └── tsconfig.json
├── docker-compose.yml
├── .env.example
└── main.tf # Legacy Terraform
Migrated from Desktop OOB to Web Application flow:
- Users connect both Google accounts via web browser
- OAuth tokens encrypted with Fernet before database storage
- Separate OAuth connections for source and destination accounts
- Tokens automatically refreshed when expired
Events tracked via extendedProperties.shared.source_id:
- Create: Source event not in destination → insert with
source_id - Update: Source event changed → update destination event with matching
source_id - Delete: Source event cancelled → delete destination event with matching
source_id - Skip: Source event unchanged → no API call
Enhanced event metadata for future 2-way sync:
- sync_cluster_id: UUID linking source ↔ destination events
- event_mappings table: Database tracking of event relationships
- Content hashing: SHA-256 for change detection
- Last modified timestamps: Conflict detection infrastructure
- Bidirectional references: Both events store each other's IDs
- ✅ Backend API with FastAPI
- ✅ SQLAlchemy models and Alembic migrations
- ✅ Google OAuth-only authentication (no passwords)
- ✅ Web OAuth flow (migrated from Desktop OOB)
- ✅ OAuth token encryption (Fernet)
- ✅ React + Material UI frontend with Google Material Design 3
- ✅ Google OAuth login page (registration happens automatically)
- ✅ Dashboard with OAuth connection status
- ✅ Bi-directional sync - events sync both ways between calendars
- ✅ One-way sync - traditional source → destination syncing
- ✅ Privacy mode - hide event details while preserving time slots
- ✅ Event color customization - 11 Google Calendar colors
- ✅ Refactored sync engine from CLI with conflict resolution
- ✅ Event mappings table with origin tracking
- ✅ Docker Compose for local development
- ✅ Calendar selection UI with dropdowns
- ✅ Sync configuration creation and management
- ✅ Manual sync trigger with detailed results
- ✅ Sync history viewer with complete audit trail
- ✅ Delete sync configurations
- ✅ Real-time sync status feedback
- ✅ Error handling and user notifications
- ✅ 128 passing tests - comprehensive unit (101 backend + 27 frontend), integration, and E2E coverage
- ✅ Bug fixes - UUID type handling, idempotency restoration, OAuth registration flow
- ✅ E2E test suite - 6 automated test scripts with real Google Calendar API (including privacy mode tests)
- ✅ Code cleanup and linting setup
- ✅ Code quality improvements - version consistency, proper logging, shared constants
- ⬜ Terraform modules for production deployment
- ⬜ Cloud SQL with Auth Proxy
- ⬜ Cloud Run deployment
- ⬜ Secret Manager integration
- ⬜ Bootstrap script for OAuth client
- ⬜ Automatic scheduled syncs (Cloud Scheduler)
- ⬜ Email notifications for sync failures
- ⬜ Calendar timezone handling improvements
- ⬜ Batch sync operations
- ⬜ Sync configuration templates
- ⬜ Recurring event instance-level operations (modify/delete single occurrences)
- ⬜ Advanced conflict resolution strategies beyond "origin wins"
Copied fields:
- Summary, description, location
- Start/end times
- Recurrence rules
- Transparency, visibility, colorId
Not copied:
- Attendees
- Attachments
- Reminders (disabled on synced events)
- Future events only (configurable lookahead window, default: 90 days)
- Manual execution (no automatic scheduling in local dev environment)
- Calendars must belong to different isolated Google accounts
- Google OAuth-only authentication (no password recovery)
- Recurring event instance-level operations not supported in E2E test helpers (use Google Calendar UI for single instance edits)
- Conflict resolution uses "origin wins" strategy (the calendar where event was originally created takes precedence)
See DEVELOPMENT.md for detailed development guide.
Backend (101 tests):
docker-compose exec backend pytest -v
docker-compose exec backend pytest -n auto # Parallel execution (faster)
docker-compose exec backend pytest --cov=app --cov-report=html # With coverageTest Coverage:
- ✅ Backend: 101 unit/integration tests with 100% pass rate
- 95% coverage on sync_engine.py (42 tests)
- 99% coverage on API endpoints (51 tests)
- E2E integration tests with real OAuth tokens (8 tests)
- ✅ Frontend: 27 tests with 100% pass rate
- Component tests with React Testing Library
- Context and integration tests
- TypeScript type checking
- ESLint code quality
Frontend:
cd frontend && npm test -- --run
cd frontend && npm run lint # ESLintComprehensive E2E test scripts for real Google Calendar API testing:
# All scripts require an access token from your session
# Get token: Login to app → Browser dev tools → localStorage → copy JWT token
# One-way sync: create, rename, move, delete
python3 backend/tests/e2e/e2e_test_auto.py <ACCESS_TOKEN>
# Bi-directional sync with multiple events
python3 backend/tests/e2e/e2e_test_bidirectional.py <ACCESS_TOKEN>
# Edge case: Delete synced event and resync (idempotency test)
python3 backend/tests/e2e/e2e_test_delete_synced.py <ACCESS_TOKEN>
# Recurring events test with edge case documentation
python3 backend/tests/e2e/e2e_test_recurring.py <ACCESS_TOKEN>
# Privacy mode test - one-way sync
python3 backend/tests/e2e/e2e_test_privacy_one_way.py <ACCESS_TOKEN>
# Privacy mode test - bi-directional sync with different placeholders
python3 backend/tests/e2e/e2e_test_privacy_bidirectional.py <ACCESS_TOKEN>Test Scripts:
e2e_test_auto.py- Fully automated one-way sync (4 tests: create, rename, move, delete)e2e_test_bidirectional.py- Bi-directional sync with 6 events (4 tests)e2e_test_delete_synced.py- Idempotency validation (5-step edge case)e2e_test_recurring.py- Recurring event handling with limitations documentede2e_test_privacy_one_way.py- Privacy mode validation for one-way synce2e_test_privacy_bidirectional.py- Privacy mode with different placeholders per direction
All scripts use calendars test-4 and test-5 by default and include automatic cleanup.
See backend/tests/e2e/README.md for comprehensive documentation.
See CLAUDE.md for detailed testing documentation and recent bug fixes.
We welcome contributions! Please see CONTRIBUTING.md for:
- Development environment setup
- Code style guidelines
- Testing requirements
- Pull request process
Read our Code of Conduct before contributing.
This project is licensed under the MIT License - see the LICENSE file for details.