docs/long-term-plans/calendar-two-way-sync-technical-analysis.md
Status: Planned
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.
Challenge: External calendar APIs require OAuth2 authentication with platform-specific implementations.
Current State:
Required Work:
Google Calendar API:
calendar.events (read/write)Microsoft Outlook/Office 365:
Cross-platform considerations:
Complexity: 🔴 HIGH - Each provider needs custom implementation, token security critical
Challenge: Map Super Productivity tasks ↔ Calendar events with different data models.
Current State:
CalendarIntegrationEvent → Task (via manual/auto-import)Required Work:
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';
}
| 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:
Complexity: 🔴 HIGH - Data model impedance mismatch + conflict resolution
Challenge: External calendars have their own conflict resolution; must reconcile with SP's vector clocks.
Current State:
Sync Scenarios:
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:
CalendarEventBindingupdated field)lastSyncedAt + hash of synced stateUser 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:
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:
Complexity: 🟡 MEDIUM-HIGH - Not as complex as internal sync, but external APIs have different semantics
Challenge: Current architecture is poll-based (5 min - 2 hours). Bidirectional sync needs faster updates.
Current State:
Calendar API Capabilities:
Webhook Challenges:
Server requirement:
Desktop/mobile webhook reception:
Webhook verification:
Solutions:
Hybrid approach:
Immediate upload after changes:
ImmediateUploadService for SuperSyncAccept eventual consistency:
Complexity: 🟡 MEDIUM - Polling is viable, webhooks are nice-to-have
Challenge: External APIs have strict rate limits; aggressive polling could hit limits.
API Limits:
Google Calendar API:
Microsoft Graph (Outlook):
Current SP Sync Patterns:
Required Optimizations:
Incremental sync:
syncToken for changes since last fetchdeltaLink for changes onlyBatch operations:
$batch endpointExponential backoff:
Selective sync:
Complexity: 🟡 MEDIUM - Well-documented patterns, but requires careful implementation
Challenge: SP's recurring task model differs from iCalendar RRULE model.
Current State:
RepeatCfg with simpler recurrence (daily/weekly/monthly)Recurring Event Scenarios:
SP Task: "Daily standup" repeats every weekday
Calendar: RRULE:FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR
✓ Straightforward mapping
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
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:
Expand recurring events:
Enhance SP's repeat model:
Complexity: 🔴 HIGH - Fundamental model mismatch requires architectural decisions
Challenge: Users have multiple calendars per provider; need flexible mapping to SP projects/contexts.
User Scenarios:
Questions to Answer:
Project mapping:
Sync scope:
Event creation:
Shared calendars:
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
Challenge: External APIs fail (network issues, auth expiry, API changes); must handle gracefully.
Failure Modes:
Auth expiry:
Network failures:
API errors:
Data corruption:
Sync loops:
Required Infrastructure:
Retry queue:
Error notifications:
Conflict UI:
Sync audit log:
Complexity: 🟡 MEDIUM - Can leverage existing SP sync error handling patterns
Challenge: Calendar data is sensitive; must maintain SP's privacy-first approach.
Privacy Principles:
New Concerns with Two-Way Sync:
OAuth tokens:
Calendar data exposure:
Third-party API privacy:
calendar.events only)Shared calendar leakage:
Required Work:
Complexity: 🟡 MEDIUM - More about policy and transparency than technical implementation
Challenge: External API dependencies make testing complex; need comprehensive mocking.
Testing Challenges:
OAuth flows:
API mocking:
Conflict scenarios:
Error conditions:
Recurring event edge cases:
Testing Strategy:
Unit tests:
Integration tests:
Manual testing:
Complexity: 🟡 MEDIUM-HIGH - Requires dedicated test infrastructure
Options:
Recommendation: Start with Google Calendar only (MVP), add Outlook in phase 2.
Options:
Read-only enhanced (current + better UX)
Write-only (tasks → events)
Full bidirectional
Recommendation: Implement in phases:
Options:
Separate entities with bindings (current architecture extends cleanly)
CalendarEventBinding table links themUnified entity (major refactor)
Recommendation: Separate entities with bindings (less risky, incremental).
Options:
Recommendation: Hybrid (same as current SP sync strategy).
| 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 |
Short Term (MVP):
Effort: ~2-3 weeks, low risk, immediate value
Medium Term (Write Capability):
Effort: ~6-8 weeks, medium risk, high value for power users
Long Term (Full Bidirectional):
Effort: ~12-16 weeks, high risk, requires careful rollout
True two-way calendar sync is achievable but non-trivial. The main hurdles are:
Super Productivity's robust Operation Log architecture is a strong foundation, but calendar sync is fundamentally different from peer-to-peer sync:
The smart path: Start with read-only enhancements, add write capability incrementally, only implement full bidirectional if user demand justifies the complexity.
The following sections provide comprehensive technical deep dives into each major hurdle, including code examples, API specifics, edge cases, and implementation strategies.
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
invalid_grant error and force re-authenticationEndpoints:
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:
common for multi-tenant, or specific tenant ID for org accountsGraph.microsoft.com/ prefix)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
invalid_grant| 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 |
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:
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']);
}
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);
});
});
}
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;
}
}
}
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)
Option 2: Encrypted Token Sync
Option 3: SuperSync Token Relay
Recommendation: Option 1 (no sync) - security over convenience.
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
}
}
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 ([email protected]) [Default] [Remove]
│ ├─ ✓ Work Calendar (import + export) → Project: Work
│ ├─ ✓ Team Events (import only)
│ └─ ☐ OOO Calendar (disabled)
└─ Personal Gmail ([email protected]) [Remove]
├─ ✓ Personal Calendar (import + export) → Project: Personal
└─ ✓ Family Calendar (import only) → Project: Family
Mapping:
// Task → Event
event.summary = task.title;
// Event → Task
task.title = event.summary || '(No title)';
Edge Cases:
Challenge: Formatting differences
<b>, <i>, <a>)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
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:
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();
}
Challenge: Calendar events don't have "isDone" concept
Options:
Option 1: Don't sync completion
Option 2: Delete event when task completed
Option 3: Use calendar-specific completion fields
status: 'cancelled'responseStatus (accepted/declined), not quite the sameOption 4: Change event color/transparency
colorId propertyshowAs: 'free' (vs 'busy')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;
}
}
Preconditions:
dueDay or dueWithTime (can't sync tasks without dates)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 },
};
}
}
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 → ∞
}
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),
},
});
}
}
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.
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:
Cons:
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.
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:
Cons:
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:
Concept: Only sync recurring events that map cleanly to SP's model. Show warning for complex patterns.
Supported Patterns:
Unsupported Patterns:
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.
Due to document length, the remaining deep dives are condensed. Key implementation details for each:
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.
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');
}
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
Recommendation: Option 2 (only sync parent) - preserves hierarchy information without creating event explosion.
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)
Challenge: Calendar events have explicit timezones, SP tasks use device local time.
Problems:
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`;
}
}
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 ]
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();
});
Given the depth of these technical hurdles, here's a pragmatic phased approach:
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.