This document covers the internal mechanics of bidirectional sync between Trello and Apple Reminders: which fields are synced, how creates and updates are processed, how the Priority custom field works, how conflicts are detected and resolved, and how echo-loops are prevented.
Fields fall into three categories based on how far they propagate:
These fields sync in both directions. Changes on either side are detected and pushed to the other.
| Trello field | Reminders field | Notes |
|---|---|---|
| Card name | Title | Truncated to 16,384 chars on both sides |
Card description (desc) |
Notes | Truncated to 16,384 chars on both sides |
Due date (due) |
Due date components | ISO 8601 ↔ DateComponents conversion |
Start date (start) |
Start date components | ISO 8601 ↔ DateComponents conversion |
Completion (dueComplete) |
isCompleted |
Boolean; uses dedicated complete/uncomplete change types |
| Priority custom field | priority (EKReminderPriority) |
Mapped via a managed “Priority” dropdown custom field; see Custom Fields |
| Card location | Location-based alarm | Trello address/coordinates ↔ EKStructuredLocation geofence alarm; server geocodes addresses when coordinates are missing |
These fields are stored on the server and displayed in the DailyRoundup app but are not written to Reminders (EventKit has no equivalent).
| Trello field | Storage | Display |
|---|---|---|
| Labels | trello_card_labels table |
Colored label pills in the task detail view |
| Attachments | sync_attachments table |
📎 name: url appended to reminder notes; attachment list in the app |
| Checklists | sync_checklists / sync_checklist_items tables |
Checklist items with completion status in the task detail view |
| Custom fields (non-priority) | trello_card_custom_field_values table |
Name/value pairs in the task detail view |
Card position (pos) |
sync_tasks.position column |
Used for ordering; Reminders uses its own list order |
| Reminders field | Trello field | Notes |
|---|---|---|
| Tag names | Labels | Reminders tags are mapped to Trello labels by name; new labels are created if no match exists |
Card creation and updates follow different paths because Trello’s webhook payloads differ significantly between the two.
Create (createCard webhook): Trello’s createCard webhook payload only includes minimal card fields (id, name, idList, pos). It omits desc, due, start, dueComplete, and custom field items. To ensure all fields are captured, the webhook handler fetches the full card from the Trello API (GET /cards/{id}?fields=all&customFieldItems=true) before recording the pending change. The priority custom field is resolved from the card’s customFieldItems using the stored option ID mappings.
The server creates a sync_task record and inserts a pending_change with change_type=create. The iOS client pulls this change, calls EventKitStore.syncCreate() to create the EKReminder, and acknowledges the change.
Update (updateCard webhook): Trello’s updateCard webhook includes only the changed fields in data.old (the previous values). The webhook handler extracts the new values from data.card for each field present in data.old. No API fetch is needed because the webhook payload contains the current values for all changed fields.
Create (new reminder detected): During the push phase of a sync cycle, SyncManager compares the current Reminders items against known SyncTask records. Unrecognized items (no matching remindersItemId) are emitted as create changes with all current field values. The server calls TrelloClient.create_card() with title, description, due date, position, and card role, then creates the sync_task record.
Update (field hash mismatch): Each SyncTask stores a hash of every synced field’s last-known value. On each sync cycle, SyncManager recomputes hashes for the current reminder and compares them against the stored hashes. Only changed fields are included in the update change payload, minimizing unnecessary Trello API calls. After the server applies the update, the client refreshes the stored hashes.
Priority is the only custom field that syncs bidirectionally. It is implemented as a managed Trello dropdown (list-type) custom field named “Priority” with three options: Low, Medium, and High. These map to EKReminderPriority values:
| Trello option | EKReminderPriority raw value | Constant |
|---|---|---|
| (none selected) | 0 |
.none |
| High | 1 |
.high |
| Medium | 5 |
.medium |
| Low | 9 |
.low |
When the first Reminders-side priority change is pushed for a list, the server calls ensure_priority_custom_field():
sync_custom_fields table.If the board is on a free Trello plan (custom fields require Standard or higher), the server logs a warning and silently skips priority sync for that list.
high → option ID) and calls PUT /cards/{id}/customField/{fieldId}/item with the selected option. Setting priority to none clears the custom field value.updateCustomFieldItem webhook fires when a user changes the Priority dropdown in Trello. The webhook handler reverse-maps the selected option ID back to a priority integer and emits a pending update change. On createCard, the priority is resolved from the card’s customFieldItems returned by the full card fetch.All other custom fields (text, number, date, checkbox, other dropdowns) are read-only: they are stored in trello_card_custom_field_values and displayed in the app but not synced to Reminders.
Conflicts occur when both Trello and Reminders modify the same field on the same task before a sync cycle runs. Only a subset of fields participate in conflict detection — last-write-wins fields are excluded.
title, description, due_date, start_date
position, is_completed, priority
When the iOS client pushes a Reminders-side update change to the server (POST /dailyroundup/sync), the server checks for unacknowledged Trello-originated pending changes on the same task that touch any of the same conflict-eligible fields:
pending_changes for the task where change_origin=trello.changed_fields.None, date format differences) to avoid false positives.conflict record is created for each conflicting field.The conflicting Trello pending changes are marked as conflicted so they are not re-applied or re-detected. The Reminders-side change is not applied — both sides remain unchanged until the user resolves the conflict.
Conflicts are resolved via POST /dailyroundup/conflicts/{id}/resolve with one of three strategies:
| Resolution | Effect |
|---|---|
keep_trello |
The Trello value is written to the sync_task and a pending change is created to update the Reminders item on the next sync cycle. No Trello API call is needed (the card already has the correct value). |
keep_reminders |
The Reminders value is pushed to the Trello card via the API, and the sync_task is updated to match. An echo-loop fingerprint is recorded to suppress the resulting updateCard webhook. |
keep_both |
The original task keeps the Reminders value. A duplicate Trello card is created with the Trello value and “ [conflict]” appended to its title. A create pending change is emitted so the duplicate appears in Reminders as a separate item. |
Boolean fields (is_completed, is_archived) only support keep_trello and keep_reminders — keep_both is rejected with a 400 error.
Unresolved conflicts are surfaced in three ways:
UNNotificationAction responses, allowing users to resolve conflicts directly from the notification without opening the app.Because every change the server makes to Trello triggers a webhook back to the server, and every change the app makes to Reminders is detected on the next sync cycle, echo-loop prevention is critical. Without it, a single change would bounce back and forth indefinitely. Four independent mechanisms work together:
echo_loop.py)When the server writes to the Trello API (e.g., creates or updates a card), it records a fingerprint in the echo_fingerprints SQLite table:
echo_loop.record("createCard", card_id) → key = "createCard:<card_id>", TTL = 30s
echo_loop.record("updateCard", card_id) → key = "updateCard:<card_id>", TTL = 30s
When a webhook arrives, the handler calls echo_loop.check_and_consume(action_type, card_id) before recording any pending change. If a matching fingerprint exists:
DELETE ... RETURNING to prevent two concurrent workers from both consuming the same fingerprint.200 OK, no pending change recorded).Fingerprints are stored in SQLite (not in-memory) so they are shared across all gunicorn worker processes. Expired entries are pruned opportunistically on each insert/check.
Fail-open design: If the database is temporarily unavailable, record() logs a warning but does not raise, and check_and_consume() returns False. This means the webhook is processed rather than suppressed — a safer default than silently dropping legitimate changes.
For createCard webhooks specifically, the handler checks whether the newly created card already has a sync_task with a reminders_item_id. If so, the card was created by the server in response to a Reminders-side change (via _apply_create), and the webhook is an echo. This check catches the race condition where the webhook arrives at a different gunicorn worker before echo_loop.record() executes.
SyncManager)The iOS/macOS app maintains an in-memory dictionary mapping remindersItemId → timestamp of the last Trello-originated apply. When detecting Reminders-side changes during the push phase:
private var recentlyApplied: [String: Date] = [:]
private let echoLoopWindow: TimeInterval = 5 // seconds
If a reminder was modified within the last 5 seconds of a Trello-originated apply (create, update, complete, delete), the change is suppressed and not pushed to the server. This prevents the app’s own EventKit writes from being detected as user changes and echoed back.
seen_action_ids)Trello may deliver the same webhook event multiple times (e.g., from board-level and list-level webhook registrations, or network retries). The is_duplicate_action() function records each Trello action.id in the seen_action_ids table with a 5-minute TTL. Subsequent deliveries of the same action are silently dropped. The check uses INSERT OR IGNORE on a UNIQUE column for atomic first-writer-wins semantics.