DailyRoundup

Sync Internals

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, how potential duplicates are detected and resolved, and how echo-loops are prevented.

Synced Fields

Fields fall into three categories based on how far they propagate:

Bidirectional fields (Trello ↔ Reminders)

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

Trello → Reminders only (display-only in the app)

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 → Trello only

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

Create vs Update Processing

Card creation and updates follow different paths because Trello’s webhook payloads differ significantly between the two.

Trello → Reminders (inbound)

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.

Reminders → Trello (outbound)

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.

Custom Fields (Priority)

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

Setup

When the first Reminders-side priority change is pushed for a list, the server calls ensure_priority_custom_field():

  1. Checks if a “Priority” dropdown custom field already exists on the Trello board.
  2. If not, creates one with Low (green), Medium (yellow), and High (red) options.
  3. Stores the custom field ID and option ID mappings in the 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.

Sync flow

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.

Conflict Detection and Resolution

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.

Conflict-eligible fields

title, description, due_date, start_date

Last-write-wins fields (no conflict detection)

position, is_completed, priority

Detection

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:

  1. Query pending_changes for the task where change_origin=trello.
  2. Extract the changed field names from each pending change’s changed_fields.
  3. Intersect with the incoming Reminders change’s fields.
  4. Normalize values (empty string ↔ None, date format differences) to avoid false positives.
  5. If any field has genuinely different values on both sides, a 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.

Resolution options

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_reminderskeep_both is rejected with a 400 error.

User interface

Unresolved conflicts are surfaced in three ways:

  1. In-app banner: The sync status bar shows a conflict count badge. Tapping it opens the conflict resolution view where each conflict shows the Trello value, Reminders value, and last-synced value side by side.
  2. Push notification: When the server detects a new conflict, it sends a visible APNs notification to all registered devices for the account. On macOS, the notification includes action buttons for quick resolution.
  3. macOS notification actions: The macOS app delegate handles UNNotificationAction responses, allowing users to resolve conflicts directly from the notification without opening the app.

Potential Duplicate Detection

When a new Reminders item is pushed to the server, the server checks whether it might already exist as a synced task before creating a new Trello card. This prevents a reminder that was created on another device (and already synced) from being duplicated if the local app sees it as new.

Duplicate detection only runs on Reminders-side create changes. It is skipped if the incoming item has no title, if the item already maps to a known sync_task, or if the item was previously reviewed and resolved as a duplicate.

Candidate selection

The server queries all active (non-retired) sync_tasks for the account whose title matches the incoming reminder’s title, case-insensitively and after trimming whitespace. Tasks in any sync list for the account are considered, not just the target list.

Scoring

Each candidate is scored against the incoming item’s fields:

Field Points Condition
Title 1 (base) Guaranteed by the candidate query
Due date +1 Both have the same date (date portion only, time ignored), or both have no due date
Description +1 Both have the same description after trimming whitespace, including both empty
Is completed +1 Completion status matches; only counted when the incoming item explicitly sends this field

The maximum possible score is 4.

Creation-date penalty

When the incoming create payload includes a created_at timestamp (the EKReminder’s creation date) and the candidate task also has a created_at, a penalty is applied based on how far apart the two timestamps are:

Time difference Penalty
≤ 1 hour None — within normal sync-delay range
> 1 hour – 24 hours −1
> 24 hours −2

This prevents recurring tasks with identical titles (e.g. weekly tasks) from being falsely flagged as duplicates when the only difference is when they were created. The penalty is skipped if either timestamp is absent or unparseable.

Threshold and ambiguity guard

A title-only match (score = 1) is never flagged. The threshold for flagging is score ≥ 2 — at least one field beyond the title must agree.

Even when the threshold is met, the duplicate is only flagged if exactly one candidate reaches the highest score. If two or more candidates tie at the top score, no duplicate is flagged: the match is considered too ambiguous to surface.

Tie-breaking

When scores are equal between candidates, a candidate in the same sync list as the incoming item is preferred over one in a different list.

Resolution

Flagged duplicates are held in the potential_duplicates table with status unresolved and surfaced to the user in the app. The incoming Reminders item is not synced until the user resolves it. Two options are available:

Resolution Effect
merge The incoming Reminders item is linked to the existing sync_task. No new Trello card is created.
keep_both The incoming item proceeds through normal create processing, producing a new Trello card alongside the existing one.

After resolution, subsequent pushes of the same reminders_item_id skip duplicate detection entirely.

User interface

Unresolved potential duplicates are shown in the sync status section of the app above the task list. Each entry shows the existing and incoming task fields side by side so the user can compare them before choosing a resolution.

Echo-Loop Prevention

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:

1. Server-side fingerprinting (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:

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.

2. Server-side task ownership check

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.

3. Client-side recency window (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.

4. Webhook deduplication (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.

Reconciliation

Reconciliation is a scheduled safety net (reconcile_list() in trello_sync.py) that periodically compares the server’s sync_tasks records against the actual cards in each Trello list. It catches discrepancies that webhooks miss: dropped deliveries, race conditions under concurrent gunicorn workers, and any other out-of-band changes.

What it does

For each active sync_task in a list, reconciliation checks whether the corresponding Trello card is still present. Cards are fetched with card_filter=all (open and archived) so that archiving a card does not cause its Reminders item to be deleted.

Situation Action
Card present Refresh label and custom-field metadata; heal missing/zero position from Trello pos
Card absent, moved to another synced list Update sync_list_id to the destination list; emit a move pending change so the client moves the Reminders item
Card absent, not in any synced list Retire the sync_task; emit a delete pending change so the client removes the Reminders item

Cross-list move detection

Before retiring a task whose card is absent, reconciliation calls TrelloClient.get_card() to fetch the card’s current idList. If the card is found in a different synced list belonging to the same account, the task is followed rather than retired. This handles two failure modes:

The extra get_card API call is skipped entirely when the account has only one synced list, since there is nowhere else for a card to go.

Cross-account follows are rejected: if get_card returns a list ID that maps to a sync_list belonging to a different account, the task is retired rather than reassigned.

Return shape

reconcile_list() returns a summary dict:

Key Description
retired_count Tasks retired (card deleted or moved to a non-synced list)
followed_count Tasks followed to another synced list (missed webhook or stale DB state)
healed_count Tasks whose missing or zero position was healed from Trello
refreshed_count Tasks whose label and custom-field metadata was refreshed
trello_card_count Number of Trello cards seen on the list
error Present only on early failure (e.g., Trello API error fetching cards)