DailyRoundup

Technical Details

System Architecture

graph TB
    subgraph Trello
        TC[Cards & Lists]
    end

    subgraph Server[roundup-server]
        WH[webhook.py<br/>Webhook receiver]
        API[api.py<br/>REST endpoints]
        TS[trello_sync.py<br/>Trello API operations]
        DB[(SQLite<br/>sync_lists · sync_tasks<br/>pending_changes · devices · conflicts)]
    end

    subgraph App[DailyRoundup · iOS / macOS]
        SM[SyncManager]
        SAC[SyncServerAPIClient]
        EKS[EventKitStore]
        SS[SettingsStore]
        ST[(SyncTask<br/>SwiftData)]
    end

    subgraph Apple
        Rem[Reminders]
        iCKV[iCloud KV]
        KC[iCloud Keychain]
        APNs[Apple Push Notification service]
    end

    TC -->|Webhook POST| WH
    WH -->|Insert pending_change| DB
    API <-->|Query / update| DB
    TS -->|REST API calls| TC
    API -->|Trigger| TS
    SAC -->|GET /changes| API
    SAC -->|POST /sync → 202 + poll| API
    SAC -->|POST /devices| API
    SM --> SAC
    SM <--> EKS
    SM <--> ST
    EKS <--> Rem
    SS <--> iCKV
    SS <--> KC
    API -->|Conflict alerts| APNs
    APNs -->|Push notifications| App

Webhook Ingest Flow

When a card is created or modified in Trello, the server receives a webhook and queues a pending change:

sequenceDiagram
    participant T as Trello
    participant WH as webhook.py
    participant DB as SQLite

    T->>WH: POST /dailyroundup/webhook/trello
    WH->>WH: Validate HMAC-SHA1 signature
    WH->>WH: Filter to handled action types<br/>(createCard, updateCard, deleteCard, …)
    WH->>DB: Look up sync_list by trello_list_id
    opt createCard (webhook payload is minimal)
        WH->>T: GET /cards/{id}?fields=all&customFieldItems=true
        T-->>WH: Full card (desc, due, start, custom fields, …)
    end
    WH->>DB: Insert pending_change<br/>(change_type, changed_fields, trello_card_id)
    WH-->>T: 200 OK

Foreground Sync Flow

Each time the app comes to the foreground, SyncManager pulls pending Trello-side changes and pushes Reminders-side changes. The outbound push uses an async background job pattern:

sequenceDiagram
    participant App as DailyRoundup
    participant Server as roundup-server
    participant EK as EventKit

    App->>App: scenePhase == .active
    App->>App: guard !isSyncing

    loop Pull: until has_more == false
        App->>Server: GET /dailyroundup/changes
        Server-->>App: { changes, has_more, server_time }

        loop For each pending change
            App->>EK: syncCreate / syncUpdate / syncComplete / syncDelete
            EK-->>App: EKReminder identifier
            App->>Server: POST /dailyroundup/changes/{id}/ack
            App->>App: Update SyncTask in SwiftData
        end
    end

    App->>Server: POST /dailyroundup/sync (Reminders changes)
    Server-->>App: 202 { job_id }

    loop Poll: until status != running
        App->>Server: GET /dailyroundup/sync/jobs/{job_id}
        Server-->>App: { status, processed, total, results }
    end

Acknowledged changes are deleted from the database, so the endpoint always returns exactly the set of un-acknowledged changes. Pagination uses after_id (the id of the last change received) when has_more is true.

The outbound sync job runs in a background thread on the server. If a job’s updated_at has not progressed for 60 seconds (e.g. the worker thread died during a gunicorn restart), the polling endpoint automatically marks it as error so the client stops polling.

Sync Internals

Bidirectional sync involves field mapping, change detection via hashing, conflict resolution, and echo-loop prevention across four independent mechanisms. For full details on synced fields, create vs update processing, custom field (Priority) handling, conflict detection and resolution, and echo-loop prevention, see Sync Internals.

App Module Structure

File Purpose
DailyRoundupApp.swift App entry point; constructs SettingsStore, EventKitStore, SyncManager
ContentView.swift Root view; triggers sync on foreground
EventKitView.swift Main view: synced lists, tasks, sync status, conflicts, and EventKitStore (EventKit wrapper)
SettingsView.swift Settings sheet and SettingsStore (iCloud KV + Keychain)
SyncListView.swift Unified sheet for all four list-pairing modes: create new, import from Trello, import from Reminders, and merge existing lists
GoogleTasksImportView.swift Multi-step sheet for importing tasks from Google Tasks into a synced list via OAuth 2.0 + PKCE
GoogleTasksClient.swift Google Tasks API client: OAuth token management, Keychain persistence, task list and task CRUD operations
SyncManager.swift Pull/apply/acknowledge sync orchestrator
SyncServerAPIClient.swift URLSession REST client for the sync server API
SyncTask.swift SwiftData model caching the Trello↔Reminders ID mapping
KeychainHelper.swift iCloud Keychain wrapper for auth token storage
HTTPRetryHelper.swift Generic async retry with exponential backoff for transient HTTP errors
MacAppDelegate.swift (macOS) NSApplicationDelegate: APNs token capture, silent push sync triggers, and conflict notification action handling
MacSyncCoordinator.swift (macOS) Persistent background sync via EventKit observation, 60-second fallback timer, App Nap prevention, and wake-from-sleep handling
MenuBarView.swift (macOS) Menu bar dropdown showing sync status, pending change count, conflict count, and Sync Now action
MacOSShims.swift (macOS) No-op stubs for iOS-only SwiftUI modifiers, allowing shared views to compile on macOS

Settings Storage and Multi-Device Sync

SettingsStore manages all app preferences using a tiered storage strategy based on whether a setting should be shared across devices or remain device-specific.

Synced settings (iCloud Key-Value Store)

Most preferences sync across devices via NSUbiquitousKeyValueStore with a UserDefaults write-through cache. The cache ensures values persist across TestFlight and App Store updates even if iCloud KV hasn’t delivered data yet on first launch. Synced settings include: server URL, synced list pairs, show completed tasks filter, collapsed list sections, move URL links to description, Google import delete mode, Google import mappings, Google account emails, and notify on sync errors.

On write, values are saved to both iCloud KV and the UserDefaults cache. On launch, init() prefers the iCloud KV value and falls back to the cache. refreshFromiCloudKV() re-reads values on app foreground, and observeExternalChanges() listens for NSUbiquitousKeyValueStore.didChangeExternallyNotification to pick up changes pushed from other devices in real time.

Secure settings (iCloud Keychain)

Auth token and Trello API key are stored in iCloud Keychain via KeychainHelper, syncing securely across devices without appearing in iCloud KV.

Device-local settings (UserDefaults only)

Some settings are intentionally device-specific:

Setting Why device-local
deviceId Stable per-device UUID sent in API calls — each device needs its own identity
localCalendarIds Maps sync_list_id → local EKCalendar.calendarIdentifier. Apple assigns different calendar identifiers on each device for the same Reminders list, so these mappings are inherently device-specific. When a synced list first appears on a new device (via iCloud KV), the sync engine resolves or creates the local Reminders calendar and stores the mapping here.
confirmedSyncListIds Tracks which synced lists have completed at least one successful sync on this device. Guards auto-delete: only lists confirmed-synced locally can be auto-deleted when their calendar disappears.

Multi-device calendar resolution flow

The synced list configuration (syncedLists) is shared across all devices via iCloud KV, telling every device what to sync. Each device independently resolves where to sync locally:

  1. iCloud KV delivers the syncedLists array to a new device.
  2. For each synced list, the sync engine looks up localCalendarIds for a cached EKCalendar identifier.
  3. If no cached identifier exists (first sync on this device), it resolves the Reminders calendar by matching the list name or creates a new one.
  4. The resolved EKCalendar.calendarIdentifier is stored in localCalendarIds for future launches.

This separation ensures that adding a synced list on one device automatically propagates the configuration, while each device maintains its own mapping to the local Reminders calendar system.

roundup-server Module Structure

File Purpose
dailyroundup/app.py Application factory and development entry point
dailyroundup/api.py Flask REST endpoints
dailyroundup/webhook.py Trello webhook receiver
dailyroundup/trello_sync.py Trello API operations
dailyroundup/db.py SQLite database layer
dailyroundup/models.py Data model dataclasses
dailyroundup/trello_client.py Trello HTTP client with auth, retry, and rate limiting
dailyroundup/notifications.py APNs push notification support
dailyroundup/echo_loop.py SQLite-backed fingerprint store for webhook echo-loop prevention
dailyroundup/log_filter.py Logging filter that redacts secrets

Database Schema

Sixteen tables store all sync state: