From 6a59098c3096f5ebd3a61ef5268cbd480b0f1519 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Tue, 20 Jan 2026 10:40:29 -1000 Subject: Implement efficient sync for Todoist and Trello APIs - Add Todoist Sync API v9 support with incremental sync tokens - Store sync tokens in SQLite for persistence across restarts - Add field filtering to Trello API calls to reduce payload size - Update handlers to use incremental sync (merge changes vs full replace) Co-Authored-By: Claude Opus 4.5 --- internal/handlers/handlers.go | 95 +++++++++++++++++++++++++++++++++++--- internal/handlers/handlers_test.go | 27 +++++++++++ 2 files changed, 115 insertions(+), 7 deletions(-) (limited to 'internal/handlers') diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 0af2bba..f53eced 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -350,9 +350,10 @@ func (h *Handler) aggregateData(ctx context.Context, forceRefresh bool) (*models return data, nil } -// fetchTasks fetches tasks from cache or API +// fetchTasks fetches tasks from cache or API using incremental sync func (h *Handler) fetchTasks(ctx context.Context, forceRefresh bool) ([]models.Task, error) { cacheKey := store.CacheKeyTodoistTasks + syncService := "todoist" // Check cache validity if !forceRefresh { @@ -362,8 +363,20 @@ func (h *Handler) fetchTasks(ctx context.Context, forceRefresh bool) ([]models.T } } - // Fetch from API - tasks, err := h.todoistClient.GetTasks(ctx) + // Get stored sync token (empty string means full sync) + syncToken, err := h.store.GetSyncToken(syncService) + if err != nil { + log.Printf("Failed to get sync token, will do full sync: %v", err) + syncToken = "" + } + + // Force full sync if requested + if forceRefresh { + syncToken = "" + } + + // Fetch using Sync API + syncResp, err := h.todoistClient.Sync(ctx, syncToken) if err != nil { // Try to return cached data even if stale cachedTasks, cacheErr := h.store.GetTasks() @@ -373,9 +386,41 @@ func (h *Handler) fetchTasks(ctx context.Context, forceRefresh bool) ([]models.T return nil, err } - // Save to cache - if err := h.store.SaveTasks(tasks); err != nil { - log.Printf("Failed to save tasks to cache: %v", err) + // Build project map from sync response + projectMap := api.BuildProjectMapFromSync(syncResp.Projects) + + // Process sync response + if syncResp.FullSync { + // Full sync: replace all tasks + tasks := api.ConvertSyncItemsToTasks(syncResp.Items, projectMap) + if err := h.store.SaveTasks(tasks); err != nil { + log.Printf("Failed to save tasks to cache: %v", err) + } + } else { + // Incremental sync: merge changes + var deletedIDs []string + for _, item := range syncResp.Items { + if item.IsDeleted || item.IsCompleted { + deletedIDs = append(deletedIDs, item.ID) + } else { + // Upsert active task + task := h.convertSyncItemToTask(item, projectMap) + if err := h.store.UpsertTask(task); err != nil { + log.Printf("Failed to upsert task %s: %v", item.ID, err) + } + } + } + // Delete removed tasks + if len(deletedIDs) > 0 { + if err := h.store.DeleteTasksByIDs(deletedIDs); err != nil { + log.Printf("Failed to delete tasks: %v", err) + } + } + } + + // Store the new sync token + if err := h.store.SetSyncToken(syncService, syncResp.SyncToken); err != nil { + log.Printf("Failed to save sync token: %v", err) } // Update cache metadata @@ -383,7 +428,43 @@ func (h *Handler) fetchTasks(ctx context.Context, forceRefresh bool) ([]models.T log.Printf("Failed to update cache metadata: %v", err) } - return tasks, nil + return h.store.GetTasks() +} + +// convertSyncItemToTask converts a sync item to a Task model +func (h *Handler) convertSyncItemToTask(item api.SyncItemResponse, projectMap map[string]string) models.Task { + task := models.Task{ + ID: item.ID, + Content: item.Content, + Description: item.Description, + ProjectID: item.ProjectID, + ProjectName: projectMap[item.ProjectID], + Priority: item.Priority, + Completed: false, + Labels: item.Labels, + URL: fmt.Sprintf("https://todoist.com/showTask?id=%s", item.ID), + } + + if item.AddedAt != "" { + if createdAt, err := time.Parse(time.RFC3339, item.AddedAt); err == nil { + task.CreatedAt = createdAt + } + } + + if item.Due != nil { + var dueDate time.Time + var err error + if item.Due.Datetime != "" { + dueDate, err = time.Parse(time.RFC3339, item.Due.Datetime) + } else if item.Due.Date != "" { + dueDate, err = time.Parse("2006-01-02", item.Due.Date) + } + if err == nil { + task.DueDate = &dueDate + } + } + + return task } // fetchNotes fetches notes from cache or filesystem diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index 3ea2a3e..1aa72cc 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "task-dashboard/internal/api" "task-dashboard/internal/config" "task-dashboard/internal/models" "task-dashboard/internal/store" @@ -82,6 +83,32 @@ func (m *mockTodoistClient) CompleteTask(ctx context.Context, taskID string) err return nil } +func (m *mockTodoistClient) Sync(ctx context.Context, syncToken string) (*api.TodoistSyncResponse, error) { + if m.err != nil { + return nil, m.err + } + // Return a mock sync response with tasks converted to sync items + items := make([]api.SyncItemResponse, 0, len(m.tasks)) + for _, task := range m.tasks { + items = append(items, api.SyncItemResponse{ + ID: task.ID, + Content: task.Content, + Description: task.Description, + ProjectID: task.ProjectID, + Priority: task.Priority, + Labels: task.Labels, + IsCompleted: task.Completed, + IsDeleted: false, + }) + } + return &api.TodoistSyncResponse{ + SyncToken: "test-sync-token", + FullSync: true, + Items: items, + Projects: []api.SyncProjectResponse{}, + }, nil +} + // mockTrelloClient creates a mock Trello client for testing type mockTrelloClient struct { boards []models.Board -- cgit v1.2.3