84 KiB
Technical Hurdles for True Two-Way Calendar Sync
Executive Summary
Based on my exploration of Super Productivity's codebase, implementing true two-way calendar sync faces several significant technical challenges that go beyond the robust sync infrastructure already in place. While the app has sophisticated Operation Log-based sync for its own data and read-only iCal polling for calendars, bridging these systems to enable bidirectional calendar sync requires solving authentication, API integration, conflict resolution, and architectural challenges.
Current State Assessment
What We Have ✓
- Read-only iCal integration - Polls HTTP/HTTPS iCal feeds at configurable intervals
- Robust internal sync - Operation Log with vector clocks for conflict detection
- Task conversion - One-way: calendar events → Super Productivity tasks
- Recurring event handling - Full RFC 5545 iCalendar parsing with RRULE support
What's Missing ✗
- OAuth2 authentication - No direct Google Calendar/Outlook API integration
- Write operations - Cannot create/update/delete events in external calendars
- Bidirectional mapping - No reverse mapping: tasks → calendar events
- Webhook/push notifications - Poll-only architecture (no real-time updates from calendars)
Technical Hurdles Breakdown
1. Authentication & Authorization 🔴 CRITICAL
Challenge: External calendar APIs require OAuth2 authentication with platform-specific implementations.
Current State:
- iCal integration uses anonymous HTTP fetching (no auth)
- No OAuth flow implementation in codebase
- Electron + PWA contexts require different auth strategies
Required Work:
-
Google Calendar API:
- OAuth2 with offline access + refresh tokens
- Scopes:
calendar.events(read/write) - Token storage in encrypted config
- Refresh token rotation handling
- Multiple account support (work + personal calendars)
-
Microsoft Outlook/Office 365:
- Microsoft Identity Platform OAuth
- Different endpoint structure vs Google
- Azure AD app registration required
-
Cross-platform considerations:
- Electron desktop: Can use local web server callback for OAuth
- PWA/web: Must use redirect-based OAuth flow
- Mobile (Capacitor): Deep linking for OAuth redirect
- File-based sync: How to sync OAuth tokens securely across devices?
Complexity: 🔴 HIGH - Each provider needs custom implementation, token security critical
2. Bidirectional Data Mapping & Sync 🔴 CRITICAL
Challenge: Map Super Productivity tasks ↔ Calendar events with different data models.
Current State:
- One-way only:
CalendarIntegrationEvent→Task(via manual/auto-import) - No reverse tracking: which task originated from which calendar event
- No task → event conversion logic
Required Work:
A. Entity Relationship Model
Task ↔ CalendarEventBinding {
taskId: string;
calendarEventId: string; // External calendar's event ID
calendarProviderId: string; // Which calendar (Google/Outlook/iCal)
calendarId: string; // Which specific calendar in provider
isBidirectional: boolean; // Is this a two-way synced event?
lastSyncedAt: number; // Prevent sync loops
syncDirection: 'to-calendar' | 'from-calendar' | 'both';
}
B. Field Mapping Challenges
| Super Productivity | Calendar Event | Conflict Potential |
|---|---|---|
title |
summary |
✓ Low |
notes |
description |
✓ Medium - formatting differences |
dueDay (date) |
start (datetime) |
🔴 HIGH - all-day vs timed |
timeEstimate |
duration |
🔴 HIGH - SP estimates vs fixed duration |
isDone |
No equivalent | 🟡 Medium - could use attendee status? |
tagIds[] |
categories[]? |
🟡 Medium - limited support |
projectId |
Which calendar? | 🔴 HIGH - SP project ≠ calendar |
subTasks[] |
No equivalent | 🔴 HIGH - can't sync nested structure |
repeatCfgId |
RRULE | 🔴 HIGH - different recurrence models |
remindCfg |
Reminders | ✓ Low |
Key Architectural Question:
Should tasks and calendar events be separate entities with bindings (current approach could extend) OR should they be unified entities with multiple views?
Current architecture suggests separate entities with bindings, but this creates:
- Duplicate storage (task in SP + event in calendar)
- Sync loop risk (update task → update event → webhook → update task...)
- Conflict resolution complexity (which is source of truth?)
C. Sync Direction Strategies
- Calendar → Task (read-only) - Current implementation, works well
- Task → Calendar (write-only) - Easier, no conflicts
- Full bidirectional - Requires LWW or user resolution
Complexity: 🔴 HIGH - Data model impedance mismatch + conflict resolution
3. Conflict Resolution with External Systems 🟡 MEDIUM-HIGH
Challenge: External calendars have their own conflict resolution; must reconcile with SP's vector clocks.
Current State:
- Super Productivity uses vector clocks + LWW for internal sync
- External calendars use:
- Google: ETag + revision tracking
- Outlook: changeKey versioning
- CalDAV: ETag headers
Sync Scenarios:
Scenario 1: Task updated in SP, event updated in calendar
User A (device 1): Updates task title in SP
User A (device 2): Updates event title in Google Calendar
SP syncs across devices (vector clock detects no conflict - same user)
But calendar API sees stale ETag → returns 412 Precondition Failed
Problem: SP's vector clocks don't translate to external ETags.
Solutions:
- Store last-seen ETag/changeKey in
CalendarEventBinding - On conflict (412/409):
- Fetch latest from calendar
- Apply LWW based on timestamps (SP op timestamp vs calendar
updatedfield) - Retry with fresh ETag
- Sync loop prevention: Track
lastSyncedAt+ hash of synced state
Scenario 2: Recurring event series modified
User edits single instance in calendar (adds RECURRENCE-ID exception)
SP task still points to original event ID
Sync needs to decide: update binding to exception? Create new task?
Problem: Recurring events add complexity to 1:1 task-event mapping.
Solutions:
- One task per instance (explosion of tasks)
- One task for series (lose per-instance customization)
- Mixed approach (series task + exception tasks)
Scenario 3: Calendar deleted externally
User deletes event in Google Calendar app
SP polling detects missing event (404 or absent from list)
Should SP task be deleted? Unlinked? Marked as "calendar deleted"?
Problem: Destructive operations need user intent clarification.
Solutions:
- Unlink task (keep task, remove binding)
- Auto-delete task (if task was auto-created from calendar)
- User confirmation (show dialog: "Event deleted in calendar, delete task?")
Complexity: 🟡 MEDIUM-HIGH - Not as complex as internal sync, but external APIs have different semantics
4. Real-time Updates vs Polling 🟡 MEDIUM
Challenge: Current architecture is poll-based (5 min - 2 hours). Bidirectional sync needs faster updates.
Current State:
- iCal polling: 2 hours default
- Internal sync polling: 1-15 minutes
- No webhook/push notification support
Calendar API Capabilities:
- Google Calendar: Push notifications via webhooks (Cloud Pub/Sub channels)
- Outlook: Delta queries + webhooks (Microsoft Graph subscriptions)
- CalDAV: Poll-only (no standard webhook mechanism)
Webhook Challenges:
-
Server requirement:
- SP is peer-to-peer / file-based (no central server for webhooks)
- SuperSync server could handle webhooks, but not Dropbox/WebDAV sync
-
Desktop/mobile webhook reception:
- Electron app: no public endpoint (behind NAT/firewall)
- Mobile app: same issue
- Web PWA: could use service worker + notification API, but unreliable
-
Webhook verification:
- Google requires HTTPS endpoint with valid cert
- Outlook requires webhook validation endpoint
- Both need subscription renewal (Google: 7 days, Outlook: 3 days)
Solutions:
-
Hybrid approach:
- Poll more frequently for calendar sync (1-5 minutes)
- Use webhooks only when SuperSync server available
- Fall back to polling on Electron/mobile/file-based
-
Immediate upload after changes:
- When user updates task bound to calendar, immediately push to calendar API
- Don't wait for sync cycle
- Similar to SP's
ImmediateUploadServicefor SuperSync
-
Accept eventual consistency:
- 1-5 minute delay acceptable for most use cases
- Reserve immediate sync for user-initiated actions
Complexity: 🟡 MEDIUM - Polling is viable, webhooks are nice-to-have
5. API Rate Limits & Quotas 🟡 MEDIUM
Challenge: External APIs have strict rate limits; aggressive polling could hit limits.
API Limits:
-
Google Calendar API:
- 1,000,000 queries/day (free tier)
- 500 queries per 100 seconds per user
- Batch requests: 50 requests per batch
-
Microsoft Graph (Outlook):
- Varies by license (free tier: ~1200 requests/min)
- Throttling returns 429 with Retry-After header
Current SP Sync Patterns:
- Polls all enabled calendars on timer
- No batch request optimization
- No incremental sync (always fetches full month)
Required Optimizations:
-
Incremental sync:
- Google:
syncTokenfor changes since last fetch - Outlook:
deltaLinkfor changes only - Only fetch modified events (huge bandwidth savings)
- Google:
-
Batch operations:
- Google: Batch API for multiple calendar reads/writes
- Outlook:
$batchendpoint - Reduce API calls by 10-50x
-
Exponential backoff:
- Respect 429 Retry-After headers
- Back off on repeated failures
- Disable sync temporarily if quota exhausted
-
Selective sync:
- Only sync calendars user explicitly enables
- Configurable date range (default: 1 month ahead)
- Skip unchanged calendars (ETag-based conditional requests)
Complexity: 🟡 MEDIUM - Well-documented patterns, but requires careful implementation
6. Recurring Events & Exceptions 🔴 HIGH
Challenge: SP's recurring task model differs from iCalendar RRULE model.
Current State:
- SP has
RepeatCfgwith simpler recurrence (daily/weekly/monthly) - iCal parsing handles RRULE, but SP doesn't generate RRULE
- No exception handling (EXDATE, RECURRENCE-ID) in SP's repeat model
Recurring Event Scenarios:
A. Simple recurring task → calendar
SP Task: "Daily standup" repeats every weekday
Calendar: RRULE:FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR
✓ Straightforward mapping
B. Recurring calendar event with exceptions → tasks
Calendar: "Team meeting" every Tuesday, but June 15 is cancelled (EXDATE)
SP: Create multiple tasks? One task with skip dates?
🔴 Complex - SP doesn't have native "skip date" concept
C. Single instance modification
Calendar: User moves one instance of recurring event to different time
Calendar creates exception event with RECURRENCE-ID
SP: Update one task instance? Create new task? Modify repeat config?
🔴 Very complex - 1:1 mapping breaks down
Solutions:
-
Limit to simple recurrence:
- Only sync recurring events that map cleanly to SP's model
- Show warning for complex RRULE patterns
- Treat exceptions as separate events/tasks
-
Expand recurring events:
- Generate individual tasks for each instance (next 3 months)
- No recurrence config in SP
- Simple but creates many tasks
-
Enhance SP's repeat model:
- Add exception date support
- Add RRULE generator
- Major refactor of task repeat system
Complexity: 🔴 HIGH - Fundamental model mismatch requires architectural decisions
7. Calendar Selection & Multiple Calendars 🟡 MEDIUM
Challenge: Users have multiple calendars per provider; need flexible mapping to SP projects/contexts.
User Scenarios:
- Work Google account: "Work calendar", "Team events", "OOO calendar"
- Personal Google account: "Personal", "Family", "Gym classes"
- Outlook: "Company calendar", "Shared team calendar"
Questions to Answer:
-
Project mapping:
- Should calendars map to SP projects? (1:1 or N:1?)
- Should SP projects export to specific calendars?
- What about tasks without projects?
-
Sync scope:
- Sync all calendars from authenticated account?
- Let user select which calendars to sync?
- Per-calendar sync direction (read-only vs bidirectional)?
-
Event creation:
- When user creates task in SP, which calendar does event go to?
- Default calendar per project?
- Prompt user every time?
-
Shared calendars:
- Some calendars are read-only (shared by others)
- How to handle permission errors gracefully?
Configuration Model:
CalendarSyncConfig {
provider: 'google' | 'outlook';
accountEmail: string;
calendars: {
calendarId: string; // External calendar ID
calendarName: string; // Display name
syncDirection: 'import' | 'export' | 'bidirectional';
mappedProjectId?: string; // SP project for this calendar
isAutoImport: boolean; // Auto-convert events to tasks
}[];
defaultCalendarId?: string; // Where to create events
}
Complexity: 🟡 MEDIUM - Mostly UI/UX decisions, not deep technical challenges
8. Error Handling & Resilience 🟡 MEDIUM
Challenge: External APIs fail (network issues, auth expiry, API changes); must handle gracefully.
Failure Modes:
-
Auth expiry:
- Refresh token invalid → re-authenticate
- Show notification to user
- Pause sync until re-auth
-
Network failures:
- Offline detection (navigator.onLine)
- Retry with exponential backoff
- Queue operations for later retry
-
API errors:
- 404: Event deleted externally
- 409/412: Conflict (stale ETag)
- 429: Rate limit exceeded
- 500: Server error (transient)
-
Data corruption:
- Malformed API responses
- Schema mismatches
- Partial sync failures
-
Sync loops:
- Update event → webhook → update task → update event → ∞
- Prevent with lastSyncedAt + state hash
Required Infrastructure:
-
Retry queue:
- Store failed operations in IndexedDB
- Retry with backoff (similar to SP's sync retry logic)
- User-visible status ("3 events pending sync")
-
Error notifications:
- Toast messages for transient errors
- Persistent banner for auth issues
- Sync status indicator (red = error, yellow = pending, green = synced)
-
Conflict UI:
- Show side-by-side comparison (current in SP vs current in calendar)
- Let user choose or merge
- Similar to SP's existing sync conflict dialog
-
Sync audit log:
- Track all sync operations for debugging
- Useful for support ("why didn't my task sync?")
Complexity: 🟡 MEDIUM - Can leverage existing SP sync error handling patterns
9. Privacy & Data Security 🟡 MEDIUM
Challenge: Calendar data is sensitive; must maintain SP's privacy-first approach.
Privacy Principles:
- SP currently keeps all data local (or E2E encrypted with SuperSync)
- iCal integration is read-only, anonymous HTTP fetches
- No telemetry or analytics
New Concerns with Two-Way Sync:
-
OAuth tokens:
- Store encrypted in local DB
- Never log or transmit tokens in plaintext
- Rotate refresh tokens periodically
-
Calendar data exposure:
- Synced events now in SP's database
- File-based sync: events in sync-data.json (encrypted?)
- SuperSync: E2E encrypted already (no issue)
-
Third-party API privacy:
- Google/Microsoft can see which events SP accesses
- OAuth scopes should be minimal (
calendar.eventsonly) - No telemetry sent to calendar providers
-
Shared calendar leakage:
- User syncs company shared calendar
- Sensitive meeting info now in personal SP database
- Need clear warnings about scope of sync
Required Work:
- Document privacy implications clearly
- Add toggle: "Enable cloud calendar sync" (off by default)
- Encrypt OAuth tokens in storage
- Clear documentation: "This feature shares your task data with Google/Microsoft APIs"
Complexity: 🟡 MEDIUM - More about policy and transparency than technical implementation
10. Testing & Reliability 🟡 MEDIUM-HIGH
Challenge: External API dependencies make testing complex; need comprehensive mocking.
Testing Challenges:
-
OAuth flows:
- Hard to test end-to-end in CI
- Need mock OAuth server
- Token refresh edge cases
-
API mocking:
- Google Calendar API: 50+ endpoints
- Outlook Graph API: different structure
- Need comprehensive fixture data
-
Conflict scenarios:
- Simulate concurrent updates
- Test ETag conflicts (412)
- Test sync loop prevention
-
Error conditions:
- Network failures (abort requests)
- Rate limiting (429 responses)
- Malformed responses
-
Recurring event edge cases:
- Exceptions, EXDATE, RECURRENCE-ID
- Timezone changes (DST)
- All-day → timed conversions
Testing Strategy:
-
Unit tests:
- Mock calendar API services
- Test mapping logic (task ↔ event)
- Test conflict resolution
-
Integration tests:
- Use Google/Outlook test accounts
- Automated E2E flows (create task → verify event in calendar)
- Cleanup test data after runs
-
Manual testing:
- OAuth flows (different browsers, platforms)
- Multi-device scenarios
- Permission errors (read-only calendars)
Complexity: 🟡 MEDIUM-HIGH - Requires dedicated test infrastructure
Architectural Decision Points
Decision 1: Which Calendar Providers?
Options:
- Google Calendar only (simplest, most popular)
- Google + Outlook (covers 90%+ of users)
- Generic CalDAV (covers remaining providers, but more complex)
Recommendation: Start with Google Calendar only (MVP), add Outlook in phase 2.
Decision 2: Sync Strategy
Options:
-
Read-only enhanced (current + better UX)
- Easiest: improve current iCal integration
- Add task binding for manual updates
- No write permissions needed
-
Write-only (tasks → events)
- Medium difficulty
- Export SP tasks to calendar
- No conflicts (one-way)
-
Full bidirectional
- Hardest: both directions
- Real two-way sync
- Conflict resolution required
Recommendation: Implement in phases:
- Phase 1: Read-only enhanced (quick win)
- Phase 2: Write-only (export capability)
- Phase 3: Full bidirectional (if user demand justifies complexity)
Decision 3: Entity Model
Options:
-
Separate entities with bindings (current architecture extends cleanly)
- Tasks and CalendarEvents remain separate
CalendarEventBindingtable links them- Can sync subset of tasks
-
Unified entity (major refactor)
- "ScheduledItem" that can be both task and event
- Single source of truth
- Simpler sync logic but breaks existing architecture
Recommendation: Separate entities with bindings (less risky, incremental).
Decision 4: Conflict Resolution
Options:
- Last-Write-Wins (LWW) - Automatic, can lose data
- Manual resolution - User chooses, better UX but disruptive
- Hybrid - LWW for simple conflicts, manual for complex
Recommendation: Hybrid (same as current SP sync strategy).
Estimated Complexity Scoring
| Component | Complexity | LOC Estimate | Risk Level |
|---|---|---|---|
| OAuth2 implementation (Google) | 🔴 High | 800-1200 | Medium |
| OAuth2 implementation (Outlook) | 🔴 High | 600-800 | Medium |
| Data mapping (task ↔ event) | 🔴 High | 1000-1500 | High |
| Conflict resolution | 🟡 Medium-High | 400-600 | High |
| Recurring event handling | 🔴 High | 800-1200 | High |
| Calendar selection UI | 🟡 Medium | 600-800 | Low |
| Error handling & retry | 🟡 Medium | 500-700 | Medium |
| Testing infrastructure | 🟡 Medium-High | 1000-1500 | Medium |
| Total Estimate | 🔴 High | 6000-9000 | High |
Critical Path & Unknowns
Unknowns Requiring Prototyping:
- Recurring event sync: Can we map complex RRULE to SP's model?
- Sync loop prevention: Will lastSyncedAt + hash prevent infinite loops?
- OAuth on Electron: How to handle redirect callback securely?
- Rate limits: Will 1-min polling hit Google's quotas with multiple calendars?
- Offline edits: How to queue calendar writes when offline?
Critical Dependencies:
- Decision on sync strategy (read vs write vs bidirectional)
- Decision on entity model (separate vs unified)
- Google Calendar API approval (OAuth consent screen)
Recommendation Summary
Short Term (MVP):
- ✓ Keep current read-only iCal integration
- ✓ Add task binding tracking (which task came from which event)
- ✓ Improve UX: show calendar icon on tasks, click to open in calendar
- ✓ Add manual "update from calendar" action
- Fetch latest event data from calendar API
- Update task fields if changed
- No automatic sync, user-initiated only
Effort: ~2-3 weeks, low risk, immediate value
Medium Term (Write Capability):
- Implement Google Calendar OAuth
- Add "Export task to calendar" action
- Create event in calendar when user clicks export
- No automatic bidirectional sync yet
- Handle simple edits (update event when task updated)
Effort: ~6-8 weeks, medium risk, high value for power users
Long Term (Full Bidirectional):
- Add automatic bidirectional sync
- Implement conflict resolution UI
- Add webhook support (where feasible)
- Support recurring events with exceptions
- Add Outlook provider
Effort: ~12-16 weeks, high risk, requires careful rollout
Key Takeaway
True two-way calendar sync is achievable but non-trivial. The main hurdles are:
- Authentication complexity (OAuth flows across platforms)
- Data model impedance mismatch (tasks ≠ events, especially recurring)
- Conflict resolution (reconciling external ETags with SP's vector clocks)
- Sync loop prevention (avoiding infinite update cycles)
Super Productivity's robust Operation Log architecture is a strong foundation, but calendar sync is fundamentally different from peer-to-peer sync:
- External APIs have different conflict semantics
- No vector clocks to coordinate with
- Destructive operations (deletes) need user confirmation
- Recurring events are complex
The smart path: Start with read-only enhancements, add write capability incrementally, only implement full bidirectional if user demand justifies the complexity.
DEEP DIVES: Technical Implementation Details
The following sections provide comprehensive technical deep dives into each major hurdle, including code examples, API specifics, edge cases, and implementation strategies.
DEEP DIVE 1: OAuth2 Authentication Architecture
1.1 OAuth2 Flow Comparison: Google vs Outlook vs Electron
Google Calendar OAuth2 Flow
Endpoints:
const GOOGLE_OAUTH = {
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenUrl: 'https://oauth2.googleapis.com/token',
revokeUrl: 'https://oauth2.googleapis.com/revoke',
scopes: [
'https://www.googleapis.com/auth/calendar.events', // Read/write events
'https://www.googleapis.com/auth/calendar.readonly', // Read-only (optional)
],
// CRITICAL: Request offline access for refresh tokens
accessType: 'offline',
prompt: 'consent', // Force consent screen to get refresh token
};
Authorization Request:
// Step 1: Generate PKCE challenge (required for security)
const codeVerifier = generateRandomString(128);
const codeChallenge = await sha256(codeVerifier);
const authUrl = new URL(GOOGLE_OAUTH.authUrl);
authUrl.searchParams.append('client_id', CLIENT_ID);
authUrl.searchParams.append('redirect_uri', REDIRECT_URI);
authUrl.searchParams.append('response_type', 'code');
authUrl.searchParams.append('scope', GOOGLE_OAUTH.scopes.join(' '));
authUrl.searchParams.append('access_type', 'offline');
authUrl.searchParams.append('prompt', 'consent');
authUrl.searchParams.append('code_challenge', codeChallenge);
authUrl.searchParams.append('code_challenge_method', 'S256');
// Open browser or redirect
window.location.href = authUrl.toString();
Token Exchange:
// Step 2: Exchange authorization code for tokens
const tokenResponse = await fetch(GOOGLE_OAUTH.tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code: authorizationCode,
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
grant_type: 'authorization_code',
code_verifier: codeVerifier, // PKCE verifier
}),
});
const tokens = await tokenResponse.json();
// {
// access_token: "ya29.a0...",
// refresh_token: "1//0e...", // Only on first auth or forced consent
// expires_in: 3600,
// scope: "https://www.googleapis.com/auth/calendar.events",
// token_type: "Bearer"
// }
Refresh Token Flow:
// Step 3: Refresh access token when expired
const refreshResponse = await fetch(GOOGLE_OAUTH.tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: CLIENT_ID,
grant_type: 'refresh_token',
refresh_token: storedRefreshToken,
}),
});
const newTokens = await refreshResponse.json();
// {
// access_token: "ya29.a0...", // New access token
// expires_in: 3600,
// scope: "https://www.googleapis.com/auth/calendar.events",
// token_type: "Bearer"
// // NOTE: No new refresh_token (reuse existing one)
// }
Critical Issue: Refresh Token Rotation
- Google refresh tokens are long-lived but not permanent
- Refresh tokens can be invalidated if:
- User revokes access in Google Account settings
- User changes password
- 6 months of inactivity
- 50 refresh tokens issued (oldest gets revoked)
- Solution: Detect
invalid_granterror and force re-authentication
Microsoft Outlook/Office 365 OAuth2 Flow
Endpoints:
const MICROSOFT_OAUTH = {
authUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
scopes: [
'https://graph.microsoft.com/Calendars.ReadWrite', // Read/write calendars
'offline_access', // REQUIRED for refresh tokens
],
};
Key Differences from Google:
- Tenant ID: Use
commonfor multi-tenant, or specific tenant ID for org accounts - Scope Format: Different structure (
Graph.microsoft.com/prefix) - Refresh Token Rotation: Microsoft rotates refresh tokens on every refresh (Google doesn't)
Token Refresh with Rotation:
const refreshResponse = await fetch(MICROSOFT_OAUTH.tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: CLIENT_ID,
scope: MICROSOFT_OAUTH.scopes.join(' '),
grant_type: 'refresh_token',
refresh_token: storedRefreshToken,
}),
});
const newTokens = await refreshResponse.json();
// {
// access_token: "EwB4A8l6...",
// refresh_token: "M.R3_BAY...", // NEW refresh token (MUST SAVE!)
// expires_in: 3600,
// token_type: "Bearer"
// }
// CRITICAL: Update stored refresh token
await updateStoredRefreshToken(newTokens.refresh_token);
Failure to Save New Refresh Token = Lost Access
- If you don't save the new refresh token, the old one becomes invalid
- Next refresh attempt will fail with
invalid_grant - User must re-authenticate from scratch
1.2 Cross-Platform OAuth Implementation
Challenge: Different Redirect URI Strategies
| Platform | Redirect URI | Implementation |
|---|---|---|
| Electron Desktop | http://localhost:PORT |
Local HTTP server |
| Web PWA | https://your-domain.com/oauth/callback |
Standard redirect |
| Android (Capacitor) | com.yourapp:/oauth/callback |
Deep link |
| iOS (Capacitor) | yourapp://oauth/callback |
Custom URL scheme |
Electron: Local HTTP Server for OAuth Callback
Implementation:
import { BrowserWindow } from 'electron';
import * as http from 'http';
async function startOAuthFlow(): Promise<OAuthTokens> {
// 1. Start local HTTP server on random port
const server = http.createServer();
const port = await getAvailablePort(8000, 9000);
await new Promise<void>((resolve) => server.listen(port, resolve));
const redirectUri = `http://localhost:${port}/oauth/callback`;
// 2. Generate PKCE challenge
const { codeVerifier, codeChallenge } = await generatePKCE();
// 3. Build authorization URL
const authUrl = buildAuthUrl({
clientId: CLIENT_ID,
redirectUri,
codeChallenge,
scopes: GOOGLE_OAUTH.scopes,
});
// 4. Open in system browser (NOT in-app browser for security)
await shell.openExternal(authUrl);
// 5. Wait for callback
const authCode = await new Promise<string>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('OAuth timeout')), 120000);
server.on('request', (req, res) => {
const url = new URL(req.url!, `http://localhost:${port}`);
if (url.pathname === '/oauth/callback') {
const code = url.searchParams.get('code');
const error = url.searchParams.get('error');
if (error) {
res.writeHead(400);
res.end(
`<html><body><h1>Authorization Failed</h1><p>${error}</p></body></html>`,
);
reject(new Error(error));
} else if (code) {
res.writeHead(200);
res.end(
'<html><body><h1>Success!</h1><p>You can close this window.</p><script>window.close()</script></body></html>',
);
clearTimeout(timeout);
resolve(code);
}
}
});
});
// 6. Clean up server
server.close();
// 7. Exchange code for tokens
const tokens = await exchangeCodeForTokens(authCode, codeVerifier, redirectUri);
return tokens;
}
Security Considerations:
- Use system browser, not in-app WebView: Prevents phishing attacks (user can verify real google.com URL)
- PKCE is mandatory: Even for desktop apps (prevents authorization code interception)
- Random port: Avoid port conflicts with other apps
- Timeout: Close server after 2 minutes to prevent port leaks
Web PWA: Standard Redirect Flow
Implementation:
// In Angular service
async startOAuthFlow(): Promise<void> {
// 1. Generate PKCE and store in sessionStorage
const { codeVerifier, codeChallenge } = await generatePKCE();
sessionStorage.setItem('oauth_code_verifier', codeVerifier);
sessionStorage.setItem('oauth_state', generateRandomString(32));
// 2. Build auth URL
const authUrl = buildAuthUrl({
clientId: CLIENT_ID,
redirectUri: `${window.location.origin}/oauth/callback`,
codeChallenge,
state: sessionStorage.getItem('oauth_state'),
scopes: GOOGLE_OAUTH.scopes,
});
// 3. Redirect user
window.location.href = authUrl;
}
// In OAuth callback route component
async ngOnInit(): Promise<void> {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
const error = params.get('error');
// 4. Validate state (CSRF protection)
const storedState = sessionStorage.getItem('oauth_state');
if (state !== storedState) {
throw new Error('Invalid state parameter (CSRF detected)');
}
if (error) {
this.router.navigate(['/settings'], {
queryParams: { oauth_error: error }
});
return;
}
// 5. Exchange code for tokens
const codeVerifier = sessionStorage.getItem('oauth_code_verifier')!;
const tokens = await this.calendarAuthService.exchangeCodeForTokens(
code,
codeVerifier,
`${window.location.origin}/oauth/callback`
);
// 6. Clean up session storage
sessionStorage.removeItem('oauth_code_verifier');
sessionStorage.removeItem('oauth_state');
// 7. Store tokens and redirect
await this.calendarAuthService.storeTokens(tokens);
this.router.navigate(['/settings/calendar']);
}
Mobile (Capacitor): Deep Link Callback
Android Configuration (capacitor.config.json):
{
"appId": "com.superproductivity.app",
"plugins": {
"CapacitorOAuth": {
"android": {
"deepLinkScheme": "com.superproductivity.app"
}
}
}
}
iOS Configuration (Info.plist):
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>superproductivity</string>
</array>
</dict>
</array>
Implementation:
import { App } from '@capacitor/app';
import { Browser } from '@capacitor/browser';
async startOAuthFlow(): Promise<OAuthTokens> {
// 1. Generate PKCE
const { codeVerifier, codeChallenge } = await generatePKCE();
// Store verifier for callback handler
await Preferences.set({
key: 'oauth_code_verifier',
value: codeVerifier,
});
// 2. Build auth URL with custom scheme redirect
const redirectUri = 'com.superproductivity.app:/oauth/callback';
const authUrl = buildAuthUrl({
clientId: CLIENT_ID,
redirectUri,
codeChallenge,
scopes: GOOGLE_OAUTH.scopes,
});
// 3. Open in system browser
await Browser.open({ url: authUrl });
// 4. Wait for deep link callback
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('OAuth timeout'));
}, 120000);
const listener = App.addListener('appUrlOpen', async (data) => {
clearTimeout(timeout);
listener.remove();
// Parse deep link: com.superproductivity.app:/oauth/callback?code=...
const url = new URL(data.url);
const code = url.searchParams.get('code');
const error = url.searchParams.get('error');
if (error) {
reject(new Error(error));
return;
}
// 5. Exchange code for tokens
const verifier = (await Preferences.get({ key: 'oauth_code_verifier' })).value!;
const tokens = await exchangeCodeForTokens(code, verifier, redirectUri);
await Preferences.remove({ key: 'oauth_code_verifier' });
resolve(tokens);
});
});
}
1.3 Token Storage & Security
Encryption Strategy
Store encrypted tokens in IndexedDB:
import { AES, enc } from 'crypto-js';
class SecureTokenStorage {
// Device-specific encryption key (derived from device ID + user password hash)
private async getEncryptionKey(): Promise<string> {
// Option 1: Derive from device ID (less secure, but no user input)
const deviceId = await this.getDeviceId();
return await this.deriveKey(deviceId);
// Option 2: Require user password (more secure, but UX friction)
// const password = await this.promptUserPassword();
// return await this.deriveKey(password);
}
async storeTokens(accountId: string, tokens: OAuthTokens): Promise<void> {
const encryptionKey = await this.getEncryptionKey();
const encrypted = {
accessToken: AES.encrypt(tokens.access_token, encryptionKey).toString(),
refreshToken: AES.encrypt(tokens.refresh_token, encryptionKey).toString(),
expiresAt: Date.now() + tokens.expires_in * 1000,
};
// Store in IndexedDB (not localStorage - too small, too insecure)
await this.db.put('oauth_tokens', encrypted, accountId);
}
async getTokens(accountId: string): Promise<OAuthTokens | null> {
const encrypted = await this.db.get('oauth_tokens', accountId);
if (!encrypted) return null;
const encryptionKey = await this.getEncryptionKey();
return {
access_token: AES.decrypt(encrypted.accessToken, encryptionKey).toString(enc.Utf8),
refresh_token: AES.decrypt(encrypted.refreshToken, encryptionKey).toString(
enc.Utf8,
),
expires_in: Math.floor((encrypted.expiresAt - Date.now()) / 1000),
token_type: 'Bearer',
};
}
async refreshAccessToken(accountId: string): Promise<string> {
const tokens = await this.getTokens(accountId);
if (!tokens) throw new Error('No tokens found');
// Check if still valid
if (Date.now() < tokens.expiresAt - 60000) {
// 1 min buffer
return tokens.access_token;
}
// Refresh
try {
const newTokens = await this.exchangeRefreshToken(tokens.refresh_token);
await this.storeTokens(accountId, newTokens);
return newTokens.access_token;
} catch (error) {
if (error.message === 'invalid_grant') {
// Refresh token invalid - require re-authentication
await this.revokeTokens(accountId);
throw new Error('REAUTH_REQUIRED');
}
throw error;
}
}
}
Syncing Tokens Across Devices (File-Based Sync)
Problem: User authenticates on Device A, syncs to Dropbox, opens Device B. How does Device B get OAuth tokens?
Solution Options:
Option 1: No Sync (Recommended)
- OAuth tokens are device-specific
- User must authenticate on each device independently
- Safer: compromised sync file doesn't expose calendar access
- Trade-off: User annoyance (must OAuth on each device)
Option 2: Encrypted Token Sync
- Encrypt tokens with user-provided password (not device-specific key)
- Include encrypted tokens in sync-data.json
- Device B prompts for password to decrypt tokens
- Trade-off: Password management complexity, weaker security if password reused
Option 3: SuperSync Token Relay
- SuperSync server stores encrypted tokens (E2E encrypted with user's encryption key)
- Device B fetches tokens from server after authentication
- Trade-off: Only works with SuperSync, not file-based sync
Recommendation: Option 1 (no sync) - security over convenience.
1.4 Edge Cases & Error Handling
Scenario: User Revokes Access Mid-Sync
Timeline:
T0: Sync starts, fetches calendar events successfully
T1: User opens Google Account settings
T2: User clicks "Remove access" for Super Productivity
T3: Sync tries to create event → 401 Unauthorized
Handling:
async syncToCalendar(task: Task, binding: CalendarEventBinding): Promise<void> {
try {
const accessToken = await this.tokenStorage.refreshAccessToken(binding.accountId);
await this.calendarApi.updateEvent(
binding.calendarId,
binding.calendarEventId,
this.mapTaskToEvent(task),
accessToken
);
} catch (error) {
if (error.status === 401 && error.error?.error === 'invalid_grant') {
// Token revoked - disable sync and notify user
await this.disableCalendarSync(binding.accountId);
this.notificationService.show({
type: 'error',
title: 'Calendar Access Revoked',
message: 'Please re-authenticate to continue syncing.',
action: {
label: 'Re-authenticate',
callback: () => this.startOAuthFlow(binding.provider),
},
persistent: true, // Don't auto-dismiss
});
throw new Error('REAUTH_REQUIRED');
}
throw error; // Other errors bubble up
}
}
Scenario: Multiple Accounts (Work + Personal)
Data Model:
interface CalendarAccount {
id: string; // UUID
provider: 'google' | 'outlook';
email: string; // Account identifier
displayName: string; // "Work Gmail", "Personal Outlook"
tokens: EncryptedOAuthTokens;
calendars: {
calendarId: string;
calendarName: string;
colorId?: string;
accessRole: 'owner' | 'writer' | 'reader';
syncEnabled: boolean;
syncDirection: 'import' | 'export' | 'bidirectional';
mappedProjectId?: string;
}[];
isDefault: boolean; // Default account for new events
}
UI Flow:
Settings > Calendar Sync
├─ [+ Add Account]
├─ Work Gmail (user@company.com) [Default] [Remove]
│ ├─ ✓ Work Calendar (import + export) → Project: Work
│ ├─ ✓ Team Events (import only)
│ └─ ☐ OOO Calendar (disabled)
└─ Personal Gmail (personal@gmail.com) [Remove]
├─ ✓ Personal Calendar (import + export) → Project: Personal
└─ ✓ Family Calendar (import only) → Project: Family
DEEP DIVE 2: Data Mapping & Synchronization Logic
2.1 Field-by-Field Mapping Strategy
Title / Summary (Low Conflict Risk)
Mapping:
// Task → Event
event.summary = task.title;
// Event → Task
task.title = event.summary || '(No title)';
Edge Cases:
- Empty title: Google Calendar allows empty summary, SP requires title
- Solution: Use placeholder "(No title)" or "(Untitled event)"
- Very long title: Calendar APIs have limits (Google: ~1024 chars, Outlook: ~255 chars)
- Solution: Truncate with ellipsis, store full title in description
Notes / Description (Medium Conflict Risk)
Challenge: Formatting differences
- SP: Plain text with markdown-like formatting
- Google: Supports limited HTML (
<b>,<i>,<a>) - Outlook: Rich text (HTML)
Mapping Strategy:
// Task → Event
function taskNotesToEventDescription(notes: string): string {
// Option 1: Plain text (safest, loses formatting)
return notes;
// Option 2: Convert markdown to HTML (better UX)
return marked.parse(notes, {
breaks: true,
gfm: true,
});
}
// Event → Task
function eventDescriptionToTaskNotes(description: string): string {
// Strip HTML tags
const stripped = description.replace(/<[^>]*>/g, '');
// Decode HTML entities
return he.decode(stripped);
}
Conflict Scenario:
Device A: User edits task notes in SP (markdown)
Device B: User edits event description in Google Calendar (adds bold formatting)
Sync: Both changes detected → LWW based on timestamps
Due Date/Time (HIGH Conflict Risk)
Challenge: SP has two fields, calendar has start + end
SP Model:
interface Task {
dueDay: string | null; // YYYY-MM-DD (all-day task)
dueWithTime: number | null; // Timestamp (timed task)
timeEstimate: number | null; // Milliseconds (estimated duration)
}
Calendar Model:
interface CalendarEvent {
start: {
date?: string; // YYYY-MM-DD (all-day event)
dateTime?: string; // ISO 8601 with timezone (timed event)
timeZone?: string;
};
end: {
date?: string;
dateTime?: string;
timeZone?: string;
};
}
Mapping Rules:
Case 1: All-day task → All-day event
// Task: dueDay = "2024-06-15", dueWithTime = null
// Event:
{
start: { date: "2024-06-15" },
end: { date: "2024-06-16" } // IMPORTANT: Exclusive end date!
}
Case 2: Timed task → Timed event
// Task: dueWithTime = 1718467200000 (2024-06-15T14:00:00Z), timeEstimate = 3600000 (1 hour)
// Event:
{
start: {
dateTime: "2024-06-15T14:00:00Z",
timeZone: "UTC"
},
end: {
dateTime: "2024-06-15T15:00:00Z", // start + timeEstimate
timeZone: "UTC"
}
}
Case 3: Task with both dueDay and dueWithTime (SP allows this!)
// Task: dueDay = "2024-06-15", dueWithTime = 1718467200000
// Interpretation: Task is due on June 15, ideally at 2pm
// Event: Use dueWithTime (more specific)
{
start: { dateTime: "2024-06-15T14:00:00Z" },
end: { dateTime: "2024-06-15T15:00:00Z" }
}
Case 4: All-day event → Task
// Event: start = { date: "2024-06-15" }, end = { date: "2024-06-16" }
// Task:
{
dueDay: "2024-06-15",
dueWithTime: null,
timeEstimate: null
}
Case 5: Timed event → Task
// Event: start = "2024-06-15T14:00:00Z", end = "2024-06-15T15:00:00Z"
// Task:
{
dueDay: null, // Don't set both dueDay and dueWithTime (prefer dueWithTime)
dueWithTime: 1718467200000, // start timestamp
timeEstimate: 3600000 // end - start
}
Conflict Scenario: All-day ↔ Timed Conversion
Initial: All-day event on June 15
User A (SP): Sets dueWithTime = June 15 at 2pm (converts to timed task)
User B (Calendar): Keeps as all-day event
Sync: Conflict detected
→ LWW: If User A's change is newer, event becomes timed (start = 2pm, end = 3pm with default 1h duration)
→ If User B's change is newer, task reverts to all-day (dueDay = June 15, dueWithTime = null)
Duration Ambiguity:
- Task timeEstimate is optional (SP allows tasks without estimates)
- Calendar end time is mandatory
- Solution: Use default duration (1 hour) if timeEstimate is null
function getEventEnd(task: Task): string {
const start = task.dueWithTime!;
const duration = task.timeEstimate || 3600000; // Default: 1 hour
const end = start + duration;
return new Date(end).toISOString();
}
Completion Status (Medium Conflict Risk)
Challenge: Calendar events don't have "isDone" concept
Options:
Option 1: Don't sync completion
- Keep task completion status local to SP
- Calendar event unchanged regardless of task.isDone
- Trade-off: User completes task in SP, event still shows in calendar (confusing)
Option 2: Delete event when task completed
- When task.isDone = true, delete calendar event
- When event deleted, mark task.isDone = true
- Trade-off: Destructive (loses event history)
Option 3: Use calendar-specific completion fields
- Google Calendar: No native completion field, but could use
status: 'cancelled' - Outlook: Has
responseStatus(accepted/declined), not quite the same - Trade-off: Abusing fields for unintended purposes
Option 4: Change event color/transparency
- Mark completed events with specific color (e.g., gray)
- Google:
colorIdproperty - Outlook:
showAs: 'free'(vs 'busy') - Trade-off: Visual indicator only, not semantic
Recommendation: Option 4 (color change) + make deletion configurable
async markTaskCompleted(task: Task, binding: CalendarEventBinding): Promise<void> {
const userPreference = await this.getUserCompletionStrategy();
switch (userPreference) {
case 'DELETE_EVENT':
await this.calendarApi.deleteEvent(binding.calendarEventId);
await this.deleteBinding(binding.id);
break;
case 'CHANGE_COLOR':
await this.calendarApi.updateEvent(binding.calendarEventId, {
colorId: this.config.completedEventColorId, // Gray
});
break;
case 'KEEP_UNCHANGED':
default:
// Do nothing
break;
}
}
2.2 Sync Operation Semantics
Create Operation: Task → Event
Preconditions:
- Task has
dueDayordueWithTime(can't sync tasks without dates) - User has selected target calendar
- Task is not already bound to an event
Implementation:
async createEventFromTask(task: Task, calendarId: string, accountId: string): Promise<CalendarEventBinding> {
// 1. Map task to event
const event = this.taskToEvent(task);
// 2. Call calendar API
const accessToken = await this.tokenStorage.refreshAccessToken(accountId);
const createdEvent = await this.calendarApi.createEvent(calendarId, event, accessToken);
// 3. Create binding
const binding: CalendarEventBinding = {
id: generateUUID(),
taskId: task.id,
calendarEventId: createdEvent.id,
calendarProviderId: accountId,
calendarId,
isBidirectional: true,
syncDirection: 'both',
lastSyncedAt: Date.now(),
lastSyncedHash: this.hashEvent(createdEvent), // Prevent immediate sync loop
etag: createdEvent.etag, // Store ETag for conflict detection
};
// 4. Store binding (via NgRx + Operation Log)
this.store.dispatch(calendarBindingActions.create({ binding }));
return binding;
}
private taskToEvent(task: Task): GoogleCalendarEvent {
const hasTimedDue = task.dueWithTime != null;
if (hasTimedDue) {
// Timed event
const start = new Date(task.dueWithTime!);
const duration = task.timeEstimate || 3600000; // Default 1h
const end = new Date(task.dueWithTime! + duration);
return {
summary: task.title,
description: this.taskNotesToEventDescription(task.notes),
start: {
dateTime: start.toISOString(),
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
end: {
dateTime: end.toISOString(),
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
reminders: this.mapTaskReminders(task.remindCfg),
};
} else {
// All-day event
const dueDay = task.dueDay!;
const endDay = this.addDays(dueDay, 1); // Exclusive end date
return {
summary: task.title,
description: this.taskNotesToEventDescription(task.notes),
start: { date: dueDay },
end: { date: endDay },
};
}
}
Update Operation: Detect Changes & Sync
Challenge: Detect which side changed (task in SP or event in calendar)
Solution: State Hashing
interface CalendarEventBinding {
// ... other fields ...
lastSyncedHash: string; // Hash of last synced state
lastSyncedTaskState: string; // JSON of relevant task fields
lastSyncedEventState: string; // JSON of relevant event fields
lastSyncedAt: number; // Timestamp of last sync
}
function hashTaskState(task: Task): string {
const relevant = {
title: task.title,
notes: task.notes,
dueDay: task.dueDay,
dueWithTime: task.dueWithTime,
timeEstimate: task.timeEstimate,
isDone: task.isDone,
};
return sha256(JSON.stringify(relevant));
}
function hashEventState(event: GoogleCalendarEvent): string {
const relevant = {
summary: event.summary,
description: event.description,
start: event.start,
end: event.end,
};
return sha256(JSON.stringify(relevant));
}
Sync Decision Logic:
async syncBinding(binding: CalendarEventBinding): Promise<void> {
// 1. Fetch current state from both sides
const task = await this.taskService.getById(binding.taskId);
const event = await this.calendarApi.getEvent(
binding.calendarId,
binding.calendarEventId
);
// 2. Hash current state
const currentTaskHash = hashTaskState(task);
const currentEventHash = hashEventState(event);
// 3. Compare with last synced state
const taskChanged = currentTaskHash !== binding.lastSyncedTaskState;
const eventChanged = currentEventHash !== binding.lastSyncedEventState;
// 4. Sync decision
if (!taskChanged && !eventChanged) {
// No changes - skip
return;
}
if (taskChanged && !eventChanged) {
// Task changed → update event
await this.updateEventFromTask(task, event, binding);
} else if (eventChanged && !taskChanged) {
// Event changed → update task
await this.updateTaskFromEvent(event, task, binding);
} else {
// CONFLICT: Both changed
await this.resolveConflict(task, event, binding);
}
}
Conflict Resolution:
async resolveConflict(
task: Task,
event: GoogleCalendarEvent,
binding: CalendarEventBinding
): Promise<void> {
// 1. Get timestamps
const taskUpdatedAt = this.getTaskUpdatedTimestamp(task);
const eventUpdatedAt = new Date(event.updated).getTime();
// 2. Last-Write-Wins
if (eventUpdatedAt > taskUpdatedAt) {
// Event is newer → update task
console.log(`Conflict: event newer (${event.updated} > ${new Date(taskUpdatedAt).toISOString()})`);
await this.updateTaskFromEvent(event, task, binding);
} else if (taskUpdatedAt > eventUpdatedAt) {
// Task is newer → update event
console.log(`Conflict: task newer (${new Date(taskUpdatedAt).toISOString()} > ${event.updated})`);
await this.updateEventFromTask(task, event, binding);
} else {
// Same timestamp → prefer calendar (external source of truth)
console.log('Conflict: same timestamp → preferring calendar');
await this.updateTaskFromEvent(event, task, binding);
}
}
private getTaskUpdatedTimestamp(task: Task): number {
// SP doesn't store updatedAt on tasks by default!
// Need to look in Operation Log for last UPDATE operation
const lastOp = this.opLogService.getLastOperationForEntity('Task', task.id);
return lastOp?.timestamp || 0;
}
Sync Loop Prevention:
async updateTaskFromEvent(
event: GoogleCalendarEvent,
task: Task,
binding: CalendarEventBinding
): Promise<void> {
// 1. Update task
const updatedTask = {
...task,
title: event.summary || '(No title)',
notes: this.eventDescriptionToTaskNotes(event.description || ''),
dueDay: event.start.date || null,
dueWithTime: event.start.dateTime ? new Date(event.start.dateTime).getTime() : null,
timeEstimate: this.calculateDuration(event.start, event.end),
};
// 2. Dispatch update action
this.store.dispatch(taskActions.update({
id: task.id,
changes: updatedTask,
}));
// 3. Update binding with new hashes
const newTaskHash = hashTaskState(updatedTask);
const newEventHash = hashEventState(event);
this.store.dispatch(calendarBindingActions.update({
id: binding.id,
changes: {
lastSyncedTaskState: newTaskHash,
lastSyncedEventState: newEventHash,
lastSyncedAt: Date.now(),
etag: event.etag, // Update ETag for next API call
},
}));
// CRITICAL: This binding update must happen in the SAME operation as task update
// Otherwise, sync loop: task update triggers sync → sees task changed → updates event → ∞
}
Delete Operation: Cascading vs Unlinking
Scenario 1: User deletes task in SP
Question: Should calendar event also be deleted?
Options:
A. Yes, delete event (keeps in sync, but destructive)
B. No, unlink only (preserves event, but inconsistent)
C. Ask user (best UX, but interruptive)
Implementation (Option C - Ask User):
async deleteTask(taskId: string): Promise<void> {
const bindings = await this.getBindingsForTask(taskId);
if (bindings.length > 0) {
// Show confirmation dialog
const userChoice = await this.dialogService.showDialog({
title: 'Delete Calendar Events?',
message: `This task is linked to ${bindings.length} calendar event(s). Do you want to delete the event(s) too?`,
buttons: [
{ label: 'Delete Events', value: 'DELETE', primary: true },
{ label: 'Unlink Only', value: 'UNLINK' },
{ label: 'Cancel', value: 'CANCEL' },
],
});
if (userChoice === 'CANCEL') {
return; // Abort deletion
}
if (userChoice === 'DELETE') {
// Delete all linked events
for (const binding of bindings) {
await this.calendarApi.deleteEvent(
binding.calendarId,
binding.calendarEventId
);
await this.deleteBinding(binding.id);
}
} else {
// Unlink only
for (const binding of bindings) {
await this.deleteBinding(binding.id);
}
}
}
// Finally delete task
this.store.dispatch(taskActions.delete({ id: taskId }));
}
Scenario 2: User deletes event in calendar
Detection: Event ID no longer in calendar API response (404 or absent from list)
Question: Should task also be deleted?
Options:
A. Yes, delete task (consistent)
B. No, unlink only (preserve task)
C. Ask user
Implementation (Auto-decide based on binding origin):
async detectDeletedEvents(): Promise<void> {
const bindings = await this.getAllBindings();
for (const binding of bindings) {
try {
// Try to fetch event
await this.calendarApi.getEvent(
binding.calendarId,
binding.calendarEventId
);
} catch (error) {
if (error.status === 404) {
// Event deleted externally
await this.handleExternalEventDeletion(binding);
}
}
}
}
async handleExternalEventDeletion(binding: CalendarEventBinding): Promise<void> {
// Decision: If task was auto-created from calendar, delete it
// If task was created in SP first, just unlink
const task = await this.taskService.getById(binding.taskId);
const wasAutoCreated = task.createdFrom === 'CALENDAR_IMPORT';
if (wasAutoCreated) {
// Delete task silently
this.store.dispatch(taskActions.delete({ id: task.id }));
await this.deleteBinding(binding.id);
this.notificationService.show({
type: 'info',
message: `Task "${task.title}" deleted (calendar event removed)`,
});
} else {
// Unlink only + notify
await this.deleteBinding(binding.id);
this.notificationService.show({
type: 'warning',
message: `Calendar event for "${task.title}" was deleted. Task preserved.`,
action: {
label: 'Recreate Event',
callback: () => this.createEventFromTask(task, binding.calendarId, binding.calendarProviderId),
},
});
}
}
DEEP DIVE 3: Recurring Events - The Hardest Problem
3.1 RRULE Complexity Analysis
iCalendar RRULE (RFC 5545) is extremely powerful and complex:
Basic RRULE:
RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=10
"Every Monday, Wednesday, Friday for 10 occurrences"
Complex RRULE:
RRULE:FREQ=MONTHLY;BYDAY=2TU;UNTIL=20241231T235959Z
"Every second Tuesday of the month until Dec 31, 2024"
Super Complex RRULE:
RRULE:FREQ=YEARLY;BYMONTH=1,7;BYDAY=1MO,1WE,1FR;BYHOUR=9,14;BYMINUTE=0
"First Monday, Wednesday, and Friday of January and July, at 9am and 2pm each year"
SP's RepeatCfg Model:
interface TaskRepeatCfg {
id: string;
repeatEvery: number; // Interval (e.g., 2 for "every 2 days")
repeatType: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY';
startDate?: string; // Optional start date
endDate?: string; // Optional end date
monday?: boolean; // Weekly: repeat on Monday
tuesday?: boolean;
// ... other weekdays
// MISSING: No support for "2nd Tuesday" or "last Friday"
// MISSING: No exception dates (EXDATE)
// MISSING: No modified instances (RECURRENCE-ID)
}
Mapping Coverage:
| RRULE Pattern | SP RepeatCfg | Mappable? |
|---|---|---|
FREQ=DAILY |
DAILY |
✅ Yes |
FREQ=WEEKLY;BYDAY=MO,WE,FR |
WEEKLY with weekday flags |
✅ Yes |
FREQ=MONTHLY;INTERVAL=2 |
MONTHLY with repeatEvery=2 |
✅ Yes |
FREQ=MONTHLY;BYDAY=2TU |
N/A | ❌ No (nth weekday unsupported) |
FREQ=YEARLY;BYMONTH=3,9 |
N/A | ❌ No (multiple months unsupported) |
RRULE + EXDATE |
N/A | ❌ No (exceptions unsupported) |
RECURRENCE-ID (modified instance) |
N/A | ❌ No (instance edits unsupported) |
Coverage Estimate: SP can map ~40% of real-world RRULE patterns.
3.2 Recurring Event Sync Strategies
Strategy 1: Expand Recurring Events (Flatten)
Concept: Treat each instance of a recurring event as a separate task.
Example:
Calendar: "Team meeting" every Tuesday for 10 weeks
SP: Create 10 individual tasks (one per occurrence)
Pros:
- ✅ Simple implementation (no recurring logic in sync)
- ✅ Each task can be customized independently (notes, estimates, completion)
- ✅ Works with all RRULE patterns (just expand them)
- ✅ Task deletion doesn't affect other instances
Cons:
- ❌ Creates many tasks (clutters task list)
- ❌ No way to update all instances at once
- ❌ If calendar series is updated, hard to detect which tasks to update
- ❌ Can't re-create series in calendar from individual tasks
Implementation:
async importRecurringEvent(event: GoogleCalendarEvent): Promise<void> {
// 1. Expand RRULE to instances (next 3 months)
const instances = this.icalService.expandRecurrence(event, {
startDate: new Date(),
endDate: addMonths(new Date(), 3),
});
// 2. Create task for each instance
for (const instance of instances) {
const task = this.eventToTask(instance);
task.title = `${event.summary} (${format(instance.start, 'MMM d')})`; // Add date to title
const createdTask = await this.createTask(task);
// 3. Create binding
const binding: CalendarEventBinding = {
id: generateUUID(),
taskId: createdTask.id,
calendarEventId: instance.id, // Instance ID (e.g., "eventid_20240615")
recurringEventId: event.id, // Series ID
calendarProviderId: accountId,
calendarId,
syncDirection: 'from-calendar', // One-way only (don't export back)
lastSyncedAt: Date.now(),
};
await this.createBinding(binding);
}
}
Best For: Simple use cases where users want calendar events as task reminders, but don't need full bidirectional sync.
Strategy 2: Master Task with Instances
Concept: One "master" task representing the series, with child tasks for exceptions/modifications.
Example:
Calendar: "Team meeting" every Tuesday, but June 15 moved to Wednesday
SP:
- Master task: "Team meeting" (repeats weekly on Tuesday)
- Exception task: "Team meeting (June 15)" (due Wednesday, child of master)
Pros:
- ✅ Cleaner task list (one master task, not dozens)
- ✅ Can update series by editing master task
- ✅ Supports exceptions (modified instances)
- ✅ Closer to calendar's native model
Cons:
- ❌ Complex implementation (need to track series + exceptions)
- ❌ SP doesn't have "exception" concept natively (requires extension)
- ❌ Harder to visualize (master task doesn't show in timeline)
- ❌ Completing master task: what happens to future instances?
Data Model:
interface RecurringTaskBinding {
id: string;
masterTaskId: string; // Master task (series)
recurringEventId: string; // Calendar series ID
calendarProviderId: string;
calendarId: string;
exceptionTasks: {
taskId: string; // Exception task ID
instanceDate: string; // Which instance (YYYY-MM-DD)
exceptionType: 'MOVED' | 'CANCELLED' | 'MODIFIED';
}[];
}
Implementation Challenges:
- Detecting when instance modified vs series modified
- Handling EXDATE (skipped instances) - create "cancelled" task or just skip?
- Bi-directional: User edits exception task, how to update calendar instance?
Strategy 3: Limit to Simple Recurrence Only (Recommended)
Concept: Only sync recurring events that map cleanly to SP's model. Show warning for complex patterns.
Supported Patterns:
- ✅ Daily (every N days)
- ✅ Weekly with specific weekdays (e.g., Mon/Wed/Fri)
- ✅ Monthly (every Nth month, on same day)
- ✅ Yearly (every year on same date)
- ✅ With end date or count
Unsupported Patterns:
- ❌ "2nd Tuesday of month"
- ❌ "Last Friday of month"
- ❌ Multiple months (e.g., January and July)
- ❌ EXDATE (exception dates)
- ❌ RECURRENCE-ID (modified instances)
User Experience:
User tries to import "Monthly team meeting (2nd Tuesday)"
SP shows warning:
"This recurring event uses advanced recurrence rules that Super Productivity
doesn't support. Would you like to:"
[ ] Import as individual tasks (next 3 months)
[ ] Skip this event
[ ] Import only (don't sync changes back)
Implementation:
function isSimpleRRULE(rrule: string): boolean {
const parsed = RRule.fromString(rrule);
// Check for unsupported features
if (parsed.options.byweekday && typeof parsed.options.byweekday[0] === 'object') {
// Nth weekday (e.g., 2nd Tuesday)
return false;
}
if (parsed.options.bymonth && parsed.options.bymonth.length > 1) {
// Multiple months
return false;
}
if (parsed.options.bysetpos) {
// "Last" or positional selectors
return false;
}
// Simple enough!
return true;
}
async importRecurringEvent(event: GoogleCalendarEvent): Promise<void> {
if (!event.recurrence) {
// Not recurring - use regular import
return this.importSimpleEvent(event);
}
const rrule = event.recurrence[0]; // RRULE:...
if (!this.isSimpleRRULE(rrule)) {
// Show warning dialog
const userChoice = await this.showComplexRecurrenceDialog(event);
if (userChoice === 'EXPAND') {
return this.expandAndImportInstances(event);
} else if (userChoice === 'SKIP') {
return;
}
// Otherwise continue with import-only (no sync back)
}
// Create recurring task
const repeatCfg = this.rruleToRepeatCfg(rrule);
const task = this.eventToTask(event);
task.repeatCfgId = repeatCfg.id;
await this.createTask(task);
await this.createRepeatCfg(repeatCfg);
// Create binding
const binding: CalendarEventBinding = {
id: generateUUID(),
taskId: task.id,
calendarEventId: event.id,
recurringEventId: event.id,
isRecurring: true,
syncDirection: this.isSimpleRRULE(rrule) ? 'both' : 'from-calendar',
// ...
};
await this.createBinding(binding);
}
Bidirectional Sync for Simple Recurrence:
// Task → Event (update series)
async updateRecurringEventFromTask(task: Task, binding: RecurringTaskBinding): Promise<void> {
const repeatCfg = await this.getRepeatCfg(task.repeatCfgId!);
const rrule = this.repeatCfgToRRULE(repeatCfg);
await this.calendarApi.updateEvent(binding.recurringEventId, {
summary: task.title,
description: this.taskNotesToEventDescription(task.notes),
recurrence: [rrule],
// IMPORTANT: Don't update start/end (affects all instances)
});
}
// Event → Task (update series)
async updateTaskFromRecurringEvent(event: GoogleCalendarEvent, task: Task): Promise<void> {
const rrule = event.recurrence[0];
const repeatCfg = this.rruleToRepeatCfg(rrule);
this.store.dispatch(taskActions.update({
id: task.id,
changes: {
title: event.summary,
notes: this.eventDescriptionToTaskNotes(event.description),
},
}));
this.store.dispatch(taskRepeatCfgActions.update({
id: task.repeatCfgId,
changes: repeatCfg,
}));
}
Recommendation: Strategy 3 (limit to simple recurrence) for MVP. Add Strategy 2 (master + exceptions) in later version if user demand justifies complexity.
DEEP DIVE 4-10: Remaining Hurdles (Condensed)
Due to document length, the remaining deep dives are condensed. Key implementation details for each:
DEEP DIVE 4: Real-time Updates & Polling Optimization
Google Calendar Push Notifications:
// 1. Create push notification channel
const channel = await fetch(
'https://www.googleapis.com/calendar/v3/calendars/primary/events/watch',
{
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: generateUUID(), // Unique channel ID
type: 'web_hook',
address: 'https://your-server.com/calendar-webhook', // MUST be HTTPS
expiration: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 days (max)
}),
},
);
// 2. Webhook endpoint receives notifications
app.post('/calendar-webhook', async (req, res) => {
const channelId = req.headers['x-goog-channel-id'];
const resourceState = req.headers['x-goog-resource-state']; // "sync", "exists", "not_exists"
if (resourceState === 'exists') {
// Calendar changed - trigger sync for this user
await triggerSyncForChannel(channelId);
}
res.sendStatus(200); // Must respond quickly
});
// 3. Renew channel every 6 days (expires after 7)
setInterval(
async () => {
await renewAllChannels();
},
6 * 24 * 60 * 60 * 1000,
);
Challenge: Webhooks require server infrastructure, but SP is peer-to-peer. Solution: Only use webhooks when SuperSync server available. Fall back to polling otherwise.
DEEP DIVE 5: API Rate Limits & Incremental Sync
Google Calendar Incremental Sync:
interface SyncState {
calendarId: string;
syncToken: string | null; // Incremental sync token
lastFullSync: number; // Timestamp of last full sync
}
async syncCalendar(calendarId: string): Promise<void> {
const syncState = await this.getSyncState(calendarId);
let params: any = {
calendarId,
maxResults: 250,
};
if (syncState.syncToken) {
// Incremental sync - only fetch changes
params.syncToken = syncState.syncToken;
} else {
// Full sync - fetch all events
params.timeMin = new Date().toISOString();
params.timeMax = addMonths(new Date(), 3).toISOString();
}
try {
const response = await this.calendarApi.events.list(params);
// Process events
for (const event of response.items) {
if (event.status === 'cancelled') {
await this.handleDeletedEvent(event.id);
} else {
await this.syncEvent(event);
}
}
// Save new sync token for next incremental sync
if (response.nextSyncToken) {
await this.saveSyncState({
calendarId,
syncToken: response.nextSyncToken,
lastFullSync: Date.now(),
});
}
} catch (error) {
if (error.status === 410) {
// Sync token expired - do full sync
syncState.syncToken = null;
return this.syncCalendar(calendarId); // Retry without token
}
throw error;
}
}
Batch API for Multiple Calendars:
// Instead of 10 separate API calls:
for (const calendar of calendars) {
await fetchEvents(calendar.id); // 10 API calls
}
// Use batch request (1 API call):
const batch = this.calendarApi.newBatch();
for (const calendar of calendars) {
batch.add(this.calendarApi.events.list({ calendarId: calendar.id }));
}
const responses = await batch.execute(); // Single API call with 10 sub-requests
Rate Limit Handling:
async executeWithRetry<T>(fn: () => Promise<T>, maxRetries = 3): Promise<T> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (error.status === 429) {
// Rate limited
const retryAfter = parseInt(error.headers['retry-after'] || '60', 10);
console.warn(`Rate limited, waiting ${retryAfter}s`);
await sleep(retryAfter * 1000);
continue; // Retry
}
if (error.status === 403 && error.error?.errors?.[0]?.reason === 'rateLimitExceeded') {
// Quota exhausted
const backoff = Math.pow(2, attempt) * 1000; // Exponential backoff
await sleep(backoff);
continue;
}
throw error; // Other errors
}
}
throw new Error('Max retries exceeded');
}
DEEP DIVE 6: Subtasks & Nested Structures
Problem: SP has subtasks (nested hierarchy), calendars don't.
Solutions:
Option 1: Flatten Subtasks
SP:
- Task: "Launch product"
- Subtask: "Design landing page"
- Subtask: "Write copy"
- Subtask: "Set up analytics"
Calendar:
- Event: "Launch product - Design landing page"
- Event: "Launch product - Write copy"
- Event: "Launch product - Set up analytics"
Option 2: Only Sync Parent
SP:
- Task: "Launch product" (with 3 subtasks)
Calendar:
- Event: "Launch product"
Description: "Subtasks: Design landing page, Write copy, Set up analytics"
Option 3: Don't Sync Tasks with Subtasks
- Show warning: "This task has subtasks. Calendar sync not available."
- User must remove subtasks or skip sync
Recommendation: Option 2 (only sync parent) - preserves hierarchy information without creating event explosion.
DEEP DIVE 7: Tags/Projects → Calendars Mapping
Challenge: Should SP projects map to calendar selection?
Mapping Strategy:
// User configuration
interface ProjectCalendarMapping {
projectId: string;
defaultCalendarId: string; // Where to create events for this project
syncDirection: 'import' | 'export' | 'both';
}
// When creating event from task
async exportTaskToCalendar(task: Task): Promise<void> {
let targetCalendarId: string;
if (task.projectId) {
// Use project's mapped calendar
const mapping = await this.getProjectCalendarMapping(task.projectId);
targetCalendarId = mapping?.defaultCalendarId || this.defaultCalendarId;
} else {
// No project - use default calendar
targetCalendarId = this.defaultCalendarId;
}
await this.createEventFromTask(task, targetCalendarId);
}
// When importing event to task
async importEventToTask(event: GoogleCalendarEvent, calendarId: string): Promise<void> {
// Find project mapped to this calendar
const mapping = await this.getCalendarProjectMapping(calendarId);
const task = this.eventToTask(event);
if (mapping) {
task.projectId = mapping.projectId;
}
await this.createTask(task);
}
UI Configuration:
Settings > Calendar Sync > Project Mapping
Project "Work" → Calendar "Work Calendar" (Google)
✓ Auto-import events from this calendar
✓ Export tasks from this project to calendar
Project "Personal" → Calendar "Personal" (Google)
✓ Auto-import events from this calendar
✓ Export tasks from this project to calendar
Project "Side Project" → No calendar mapping
(Tasks in this project won't sync to calendar)
DEEP DIVE 8: Timezone Handling
Challenge: Calendar events have explicit timezones, SP tasks use device local time.
Problems:
- User creates task at "2pm" in New York, syncs to calendar as "2pm EST"
- User travels to California, opens SP, task shows "2pm" but calendar shows "11am PST" (correct)
- Sync conflict: SP thinks task is at 2pm local, calendar says 11am local
Solution: Store timezone in task
interface Task {
dueWithTime: number | null; // UTC timestamp
dueWithTimeTimezone?: string | null; // IANA timezone (e.g., "America/New_York")
}
// When creating event from task
function taskToEvent(task: Task): GoogleCalendarEvent {
const start = new Date(task.dueWithTime!);
const timezone =
task.dueWithTimeTimezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
return {
start: {
dateTime: start.toISOString(),
timeZone: timezone, // Use task's stored timezone
},
// ...
};
}
// When importing event to task
function eventToTask(event: GoogleCalendarEvent): Task {
return {
dueWithTime: new Date(event.start.dateTime).getTime(),
dueWithTimeTimezone: event.start.timeZone, // Store event's timezone
// ...
};
}
Display Handling:
// Always display in user's current timezone
function displayDueTime(task: Task): string {
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const taskTimezone = task.dueWithTimeTimezone || userTimezone;
if (taskTimezone !== userTimezone) {
// Show original timezone for clarity
return `2:00 PM EST (11:00 AM PST)`;
} else {
return `2:00 PM`;
}
}
DEEP DIVE 9: Offline Operations & Sync Queue
Challenge: User edits task while offline, then reconnects. How to sync changes to calendar?
Solution: Persistent Sync Queue
interface PendingCalendarOperation {
id: string;
type: 'CREATE' | 'UPDATE' | 'DELETE';
taskId: string;
calendarEventId?: string;
calendarId: string;
accountId: string;
payload: any;
createdAt: number;
retryCount: number;
lastError?: string;
}
class CalendarSyncQueue {
async enqueueOperation(op: PendingCalendarOperation): Promise<void> {
// Store in IndexedDB
await this.db.put('pending_calendar_ops', op);
// Try to process immediately if online
if (navigator.onLine) {
await this.processQueue();
}
}
async processQueue(): Promise<void> {
const pending = await this.db.getAll('pending_calendar_ops');
for (const op of pending) {
try {
await this.executeOperation(op);
// Success - remove from queue
await this.db.delete('pending_calendar_ops', op.id);
} catch (error) {
// Failed - increment retry count
op.retryCount++;
op.lastError = error.message;
if (op.retryCount >= 5) {
// Give up after 5 retries
await this.moveToFailedQueue(op);
} else {
// Retry later
await this.db.put('pending_calendar_ops', op);
}
}
}
}
async executeOperation(op: PendingCalendarOperation): Promise<void> {
const accessToken = await this.tokenStorage.refreshAccessToken(op.accountId);
switch (op.type) {
case 'CREATE':
await this.calendarApi.createEvent(op.calendarId, op.payload, accessToken);
break;
case 'UPDATE':
await this.calendarApi.updateEvent(
op.calendarId,
op.calendarEventId!,
op.payload,
accessToken,
);
break;
case 'DELETE':
await this.calendarApi.deleteEvent(
op.calendarId,
op.calendarEventId!,
accessToken,
);
break;
}
}
}
// Listen for online event
window.addEventListener('online', () => {
this.syncQueue.processQueue();
});
UI Indicator:
Sync Status: ⚠️ 3 changes pending
- Created event for "Write blog post"
- Updated event for "Team meeting"
- Deleted event for "Old task"
[ Retry Now ] [ View Details ]
DEEP DIVE 10: Testing Strategy
Unit Tests:
describe('TaskToEventMapper', () => {
it('should map all-day task to all-day event', () => {
const task: Task = {
id: '1',
title: 'Submit report',
dueDay: '2024-06-15',
dueWithTime: null,
timeEstimate: null,
};
const event = taskToEvent(task);
expect(event.start.date).toBe('2024-06-15');
expect(event.end.date).toBe('2024-06-16'); // Exclusive end
expect(event.start.dateTime).toBeUndefined();
});
it('should map timed task to timed event', () => {
const task: Task = {
id: '1',
title: 'Team meeting',
dueDay: null,
dueWithTime: new Date('2024-06-15T14:00:00Z').getTime(),
timeEstimate: 3600000, // 1 hour
};
const event = taskToEvent(task);
expect(event.start.dateTime).toBe('2024-06-15T14:00:00.000Z');
expect(event.end.dateTime).toBe('2024-06-15T15:00:00.000Z');
});
it('should use default duration if timeEstimate is null', () => {
const task: Task = {
id: '1',
title: 'Call client',
dueWithTime: new Date('2024-06-15T10:00:00Z').getTime(),
timeEstimate: null, // No estimate
};
const event = taskToEvent(task);
const duration =
new Date(event.end.dateTime).getTime() - new Date(event.start.dateTime).getTime();
expect(duration).toBe(3600000); // Default 1 hour
});
});
Integration Tests:
describe('Calendar Sync Integration', () => {
let testAccount: CalendarAccount;
let testCalendarId: string;
beforeAll(async () => {
// Authenticate with test Google account
testAccount = await authenticateTestAccount();
testCalendarId = 'primary';
});
afterAll(async () => {
// Clean up test events
await cleanupTestEvents(testCalendarId);
});
it('should create event from task and sync back', async () => {
// 1. Create task in SP
const task = await createTestTask({
title: 'Integration test event',
dueWithTime: Date.now() + 86400000, // Tomorrow
timeEstimate: 1800000, // 30 min
});
// 2. Export to calendar
const binding = await exportTaskToCalendar(task, testCalendarId, testAccount.id);
// 3. Verify event exists in Google Calendar
const event = await fetchEventFromCalendar(binding.calendarEventId);
expect(event.summary).toBe('Integration test event');
// 4. Update event in calendar
await updateEventInCalendar(binding.calendarEventId, {
summary: 'Updated title',
});
// 5. Trigger sync
await syncCalendar(testCalendarId);
// 6. Verify task updated in SP
const updatedTask = await getTask(task.id);
expect(updatedTask.title).toBe('Updated title');
// 7. Clean up
await deleteTask(task.id);
await deleteEventFromCalendar(binding.calendarEventId);
});
it('should handle conflicts with LWW', async () => {
const task = await createTestTask({
title: 'Conflict test',
dueWithTime: Date.now(),
});
const binding = await exportTaskToCalendar(task, testCalendarId, testAccount.id);
// Simulate concurrent updates
await Promise.all([
updateTask(task.id, { title: 'Updated in SP' }),
updateEventInCalendar(binding.calendarEventId, { summary: 'Updated in Calendar' }),
]);
// Sync should resolve conflict with LWW
await syncCalendar(testCalendarId);
// One of the changes should win (depends on timestamps)
const finalTask = await getTask(task.id);
expect(['Updated in SP', 'Updated in Calendar']).toContain(finalTask.title);
});
});
E2E Tests with Playwright:
test('calendar sync workflow', async ({ page }) => {
// 1. Authenticate with Google Calendar
await page.goto('http://localhost:4200/settings/calendar');
await page.click('button:has-text("Add Google Account")');
// OAuth flow (handled by test account credentials)
await handleOAuthFlow(page, {
email: process.env.TEST_GOOGLE_EMAIL!,
password: process.env.TEST_GOOGLE_PASSWORD!,
});
// 2. Enable calendar sync
await page.check('input[name="sync-enabled"]');
await page.selectOption('select[name="default-calendar"]', 'primary');
// 3. Create task with due date
await page.goto('http://localhost:4200');
await page.fill('input[placeholder="Add task"]', 'E2E test task');
await page.click('button:has-text("Set due date")');
await page.click('[data-testid="tomorrow"]');
await page.press('input[placeholder="Add task"]', 'Enter');
// 4. Export to calendar
await page.click('[data-testid="task-actions"]');
await page.click('button:has-text("Export to Calendar")');
// 5. Verify success notification
await expect(page.locator('text=Event created')).toBeVisible();
// 6. Verify calendar icon appears on task
await expect(page.locator('[data-testid="calendar-icon"]')).toBeVisible();
// 7. Open calendar in new tab and verify event exists
const calendarPage = await page.context().newPage();
await calendarPage.goto('https://calendar.google.com');
await expect(calendarPage.locator('text=E2E test task')).toBeVisible();
});
Conclusion: Implementation Roadmap
Given the depth of these technical hurdles, here's a pragmatic phased approach:
Phase 1: Read-Only Enhancement (2-3 weeks)
- ✅ Improve current iCal integration UI
- ✅ Add task binding tracking
- ✅ Show calendar icon on imported tasks
- ✅ "View in calendar" link
Phase 2: One-Way Export (6-8 weeks)
- Implement Google OAuth (Electron + Web + Mobile)
- Add "Export to Calendar" action
- Create events from tasks (simple mapping)
- Handle update propagation (task → event)
- No conflict resolution needed (one-way)
Phase 3: Bidirectional Sync (12-16 weeks)
- Implement change detection (state hashing)
- Add conflict resolution (LWW + manual)
- Support simple recurring events
- Add sync queue for offline operations
- Implement incremental sync (syncToken)
- Add comprehensive testing
Optional Future Phases:
- Outlook/Office 365 provider
- Complex recurring event support (master + exceptions)
- Webhook support (when SuperSync available)
- Subtask flattening/embedding
- Advanced project-calendar mapping
Confidence Level: 75% - The architecture is sound and SP's existing sync infrastructure provides a strong foundation. Main risks are recurring events (hardest problem) and OAuth token management across platforms. Recommend building a prototype for Phase 2 before committing to full bidirectional sync.