summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-22 23:45:19 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-22 23:45:19 +0000
commit8abc63efdbc0bb96cd6c9aa99d6e9166e0bcabae (patch)
treef4d6a082eed9b10bc67436a3ca5188e0182961eb /internal
parent11b905fd437d651b2e39745aa82a5dd36f70331e (diff)
chore: unify and centralize agent configuration in .agent/
Diffstat (limited to 'internal')
-rw-r--r--internal/api/interfaces.go1
-rw-r--r--internal/api/todoist.go155
-rw-r--r--internal/api/todoist_test.go166
-rw-r--r--internal/handlers/handlers.go62
-rw-r--r--internal/handlers/handlers_test.go216
-rw-r--r--internal/store/sqlite.go6
6 files changed, 111 insertions, 495 deletions
diff --git a/internal/api/interfaces.go b/internal/api/interfaces.go
index c9962c9..cec64be 100644
--- a/internal/api/interfaces.go
+++ b/internal/api/interfaces.go
@@ -15,7 +15,6 @@ type TodoistAPI interface {
UpdateTask(ctx context.Context, taskID string, updates map[string]interface{}) error
CompleteTask(ctx context.Context, taskID string) error
ReopenTask(ctx context.Context, taskID string) error
- Sync(ctx context.Context, syncToken string) (*TodoistSyncResponse, error)
}
// TrelloAPI defines the interface for Trello operations
diff --git a/internal/api/todoist.go b/internal/api/todoist.go
index be699ce..d6058d3 100644
--- a/internal/api/todoist.go
+++ b/internal/api/todoist.go
@@ -10,22 +10,19 @@ import (
)
const (
- todoistBaseURL = "https://api.todoist.com/api/v1"
- todoistSyncBaseURL = "https://api.todoist.com/sync/v9"
+ todoistBaseURL = "https://api.todoist.com/api/v1"
)
// TodoistClient handles interactions with the Todoist API
type TodoistClient struct {
BaseClient
- syncClient BaseClient
- apiKey string
+ apiKey string
}
// NewTodoistClient creates a new Todoist API client
func NewTodoistClient(apiKey string) *TodoistClient {
return &TodoistClient{
BaseClient: NewBaseClient(todoistBaseURL),
- syncClient: NewBaseClient(todoistSyncBaseURL),
apiKey: apiKey,
}
}
@@ -53,43 +50,33 @@ type todoistProjectResponse struct {
Name string `json:"name"`
}
-// Sync API v9 response types
-// TodoistSyncResponse represents the Sync API response
-type TodoistSyncResponse struct {
- SyncToken string `json:"sync_token"`
- FullSync bool `json:"full_sync"`
- Items []SyncItemResponse `json:"items"`
- Projects []SyncProjectResponse `json:"projects"`
-}
-
-// SyncItemResponse represents a task item from Sync API
-type SyncItemResponse struct {
- ID string `json:"id"`
- Content string `json:"content"`
- Description string `json:"description"`
- ProjectID string `json:"project_id"`
- Priority int `json:"priority"`
- Labels []string `json:"labels"`
- Due *dueInfo `json:"due"`
- IsCompleted bool `json:"is_completed"`
- IsDeleted bool `json:"is_deleted"`
- AddedAt string `json:"added_at"`
-}
-
-// SyncProjectResponse represents a project from Sync API
-type SyncProjectResponse struct {
- ID string `json:"id"`
- Name string `json:"name"`
- IsDeleted bool `json:"is_deleted"`
+// todoistTasksPage represents the paginated response from the Todoist REST API v1
+type todoistTasksPage struct {
+ Results []todoistTaskResponse `json:"results"`
+ NextCursor *string `json:"next_cursor"`
}
// GetTasks fetches all active tasks from Todoist
func (c *TodoistClient) GetTasks(ctx context.Context) ([]models.Task, error) {
- var apiTasks []todoistTaskResponse
- if err := c.Get(ctx, "/tasks", c.authHeaders(), &apiTasks); err != nil {
- return nil, fmt.Errorf("failed to fetch tasks: %w", err)
+ var allTasks []todoistTaskResponse
+ cursor := ""
+ for {
+ path := "/tasks"
+ if cursor != "" {
+ path = "/tasks?cursor=" + cursor
+ }
+ var page todoistTasksPage
+ if err := c.Get(ctx, path, c.authHeaders(), &page); err != nil {
+ return nil, fmt.Errorf("failed to fetch tasks: %w", err)
+ }
+ allTasks = append(allTasks, page.Results...)
+ if page.NextCursor == nil || *page.NextCursor == "" {
+ break
+ }
+ cursor = *page.NextCursor
}
+ apiTasks := allTasks
// Fetch projects to get project names
projects, err := c.GetProjects(ctx)
@@ -129,12 +116,32 @@ func (c *TodoistClient) GetTasks(ctx context.Context) ([]models.Task, error) {
return tasks, nil
}
+// todoistProjectsPage represents the paginated response for projects
+type todoistProjectsPage struct {
+ Results []todoistProjectResponse `json:"results"`
+ NextCursor *string `json:"next_cursor"`
+}
+
// GetProjects fetches all projects
func (c *TodoistClient) GetProjects(ctx context.Context) ([]models.Project, error) {
- var apiProjects []todoistProjectResponse
- if err := c.Get(ctx, "/projects", c.authHeaders(), &apiProjects); err != nil {
- return nil, fmt.Errorf("failed to fetch projects: %w", err)
+ var allProjects []todoistProjectResponse
+ cursor := ""
+ for {
+ path := "/projects"
+ if cursor != "" {
+ path = "/projects?cursor=" + cursor
+ }
+ var page todoistProjectsPage
+ if err := c.Get(ctx, path, c.authHeaders(), &page); err != nil {
+ return nil, fmt.Errorf("failed to fetch projects: %w", err)
+ }
+ allProjects = append(allProjects, page.Results...)
+ if page.NextCursor == nil || *page.NextCursor == "" {
+ break
+ }
+ cursor = *page.NextCursor
}
+ apiProjects := allProjects
projects := make([]models.Project, 0, len(apiProjects))
for _, apiProj := range apiProjects {
@@ -147,76 +154,6 @@ func (c *TodoistClient) GetProjects(ctx context.Context) ([]models.Project, erro
return projects, nil
}
-// Sync performs an incremental sync using the Sync API v9
-func (c *TodoistClient) Sync(ctx context.Context, syncToken string) (*TodoistSyncResponse, error) {
- if syncToken == "" {
- syncToken = "*" // Full sync
- }
-
- payload := map[string]interface{}{
- "sync_token": syncToken,
- "resource_types": []string{"items", "projects"},
- }
-
- var syncResp TodoistSyncResponse
- if err := c.syncClient.Post(ctx, "/sync", c.authHeaders(), payload, &syncResp); err != nil {
- return nil, fmt.Errorf("failed to perform sync: %w", err)
- }
-
- return &syncResp, nil
-}
-
-// ConvertSyncItemToTask converts a single sync item to a Task model.
-// Returns the task and true if the item is active, or a zero Task and false if it should be skipped.
-func ConvertSyncItemToTask(item SyncItemResponse, projectMap map[string]string) (models.Task, bool) {
- if item.IsCompleted || item.IsDeleted {
- return models.Task{}, false
- }
-
- 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/app/task/%s", item.ID),
- }
-
- if item.AddedAt != "" {
- if createdAt, err := time.Parse(time.RFC3339, item.AddedAt); err == nil {
- task.CreatedAt = createdAt
- }
- }
-
- task.DueDate = parseDueDate(item.Due)
- return task, true
-}
-
-// ConvertSyncItemsToTasks converts sync API items to Task models
-func ConvertSyncItemsToTasks(items []SyncItemResponse, projectMap map[string]string) []models.Task {
- tasks := make([]models.Task, 0, len(items))
- for _, item := range items {
- if task, ok := ConvertSyncItemToTask(item, projectMap); ok {
- tasks = append(tasks, task)
- }
- }
- return tasks
-}
-
-// BuildProjectMapFromSync builds a project ID to name map from sync response
-func BuildProjectMapFromSync(projects []SyncProjectResponse) map[string]string {
- projectMap := make(map[string]string)
- for _, proj := range projects {
- if !proj.IsDeleted {
- projectMap[proj.ID] = proj.Name
- }
- }
- return projectMap
-}
-
// CreateTask creates a new task in Todoist
func (c *TodoistClient) CreateTask(ctx context.Context, content, projectID string, dueDate *time.Time, priority int) (*models.Task, error) {
payload := map[string]interface{}{"content": content}
diff --git a/internal/api/todoist_test.go b/internal/api/todoist_test.go
index 2204469..99b9e80 100644
--- a/internal/api/todoist_test.go
+++ b/internal/api/todoist_test.go
@@ -14,7 +14,6 @@ import (
func newTestTodoistClient(baseURL, apiKey string) *TodoistClient {
client := NewTodoistClient(apiKey)
client.BaseURL = baseURL
- client.syncClient.BaseURL = baseURL
return client
}
@@ -220,9 +219,11 @@ func TestTodoistClient_GetProjects(t *testing.T) {
}
// Return mock response
- response := []todoistProjectResponse{
- {ID: "proj-1", Name: "Project 1"},
- {ID: "proj-2", Name: "Project 2"},
+ response := todoistProjectsPage{
+ Results: []todoistProjectResponse{
+ {ID: "proj-1", Name: "Project 1"},
+ {ID: "proj-2", Name: "Project 2"},
+ },
}
w.Header().Set("Content-Type", "application/json")
@@ -265,17 +266,21 @@ func TestTodoistClient_GetTasks(t *testing.T) {
// GetTasks also calls GetProjects internally
if r.URL.Path == "/projects" {
- response := []todoistProjectResponse{
- {ID: "proj-1", Name: "Project 1"},
+ response := todoistProjectsPage{
+ Results: []todoistProjectResponse{
+ {ID: "proj-1", Name: "Project 1"},
+ },
}
json.NewEncoder(w).Encode(response)
return
}
if r.URL.Path == "/tasks" {
- response := []todoistTaskResponse{
- {ID: "task-1", Content: "Task 1", ProjectID: "proj-1", CreatedAt: time.Now().Format(time.RFC3339)},
- {ID: "task-2", Content: "Task 2", ProjectID: "proj-1", CreatedAt: time.Now().Format(time.RFC3339)},
+ response := todoistTasksPage{
+ Results: []todoistTaskResponse{
+ {ID: "task-1", Content: "Task 1", ProjectID: "proj-1", CreatedAt: time.Now().Format(time.RFC3339)},
+ {ID: "task-2", Content: "Task 2", ProjectID: "proj-1", CreatedAt: time.Now().Format(time.RFC3339)},
+ },
}
json.NewEncoder(w).Encode(response)
return
@@ -345,146 +350,3 @@ func TestTodoistClient_UpdateTask(t *testing.T) {
}
}
-func TestTodoistClient_Sync(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != "POST" {
- t.Errorf("Expected POST, got %s", r.Method)
- }
- if r.URL.Path != "/sync" {
- t.Errorf("Expected path /sync, got %s", r.URL.Path)
- }
-
- response := TodoistSyncResponse{
- SyncToken: "new-sync-token",
- FullSync: true,
- Items: []SyncItemResponse{
- {ID: "item-1", Content: "Item 1", ProjectID: "proj-1"},
- },
- Projects: []SyncProjectResponse{
- {ID: "proj-1", Name: "Project 1"},
- },
- }
-
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(response)
- }))
- defer server.Close()
-
- client := newTestTodoistClient(server.URL, "test-key")
- resp, err := client.Sync(context.Background(), "*")
- if err != nil {
- t.Fatalf("Sync failed: %v", err)
- }
-
- if resp.SyncToken != "new-sync-token" {
- t.Errorf("Expected sync token 'new-sync-token', got '%s'", resp.SyncToken)
- }
- if len(resp.Items) != 1 {
- t.Errorf("Expected 1 item, got %d", len(resp.Items))
- }
-}
-
-func TestConvertSyncItemsToTasks(t *testing.T) {
- projects := map[string]string{
- "proj-1": "Project 1",
- }
-
- items := []SyncItemResponse{
- {
- ID: "item-1",
- Content: "Task 1",
- Description: "Description 1",
- ProjectID: "proj-1",
- Priority: 3,
- Labels: []string{"label1"},
- },
- {
- ID: "item-2",
- Content: "Completed Task",
- ProjectID: "proj-1",
- IsCompleted: true,
- },
- }
-
- tasks := ConvertSyncItemsToTasks(items, projects)
-
- // Should skip completed task
- if len(tasks) != 1 {
- t.Errorf("Expected 1 task (excluding completed), got %d", len(tasks))
- }
-
- if tasks[0].ID != "item-1" {
- t.Errorf("Expected task ID 'item-1', got '%s'", tasks[0].ID)
- }
- if tasks[0].ProjectName != "Project 1" {
- t.Errorf("Expected project name 'Project 1', got '%s'", tasks[0].ProjectName)
- }
-}
-
-func TestConvertSyncItemToTask(t *testing.T) {
- projects := map[string]string{"proj-1": "Project 1"}
-
- t.Run("active item returns task and true", func(t *testing.T) {
- item := SyncItemResponse{
- ID: "item-1",
- Content: "Active Task",
- Description: "desc",
- ProjectID: "proj-1",
- Priority: 2,
- Labels: []string{"work"},
- AddedAt: "2026-01-01T00:00:00Z",
- }
- task, ok := ConvertSyncItemToTask(item, projects)
- if !ok {
- t.Fatal("expected ok=true for active item")
- }
- if task.ID != "item-1" {
- t.Errorf("expected ID 'item-1', got '%s'", task.ID)
- }
- if task.Content != "Active Task" {
- t.Errorf("expected Content 'Active Task', got '%s'", task.Content)
- }
- if task.ProjectName != "Project 1" {
- t.Errorf("expected ProjectName 'Project 1', got '%s'", task.ProjectName)
- }
- if task.Completed {
- t.Error("expected Completed=false")
- }
- if task.URL != "https://todoist.com/app/task/item-1" {
- t.Errorf("unexpected URL: %s", task.URL)
- }
- })
-
- t.Run("completed item returns false", func(t *testing.T) {
- item := SyncItemResponse{ID: "item-2", Content: "Done", ProjectID: "proj-1", IsCompleted: true}
- _, ok := ConvertSyncItemToTask(item, projects)
- if ok {
- t.Error("expected ok=false for completed item")
- }
- })
-
- t.Run("deleted item returns false", func(t *testing.T) {
- item := SyncItemResponse{ID: "item-3", Content: "Gone", ProjectID: "proj-1", IsDeleted: true}
- _, ok := ConvertSyncItemToTask(item, projects)
- if ok {
- t.Error("expected ok=false for deleted item")
- }
- })
-}
-
-func TestBuildProjectMapFromSync(t *testing.T) {
- projects := []SyncProjectResponse{
- {ID: "proj-1", Name: "Project 1"},
- {ID: "proj-2", Name: "Project 2"},
- }
-
- projectMap := BuildProjectMapFromSync(projects)
-
- if len(projectMap) != 2 {
- t.Errorf("Expected 2 projects in map, got %d", len(projectMap))
- }
-
- if projectMap["proj-1"] != "Project 1" {
- t.Errorf("Expected 'Project 1', got '%s'", projectMap["proj-1"])
- }
-}
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index c2e903f..b0fd952 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -327,10 +327,9 @@ func filterAndSortTrelloTasks(boards []models.Board) []models.Card {
return tasks
}
-// fetchTasks fetches tasks from cache or API using incremental sync
+// fetchTasks fetches tasks from cache or API
func (h *Handler) fetchTasks(ctx context.Context, forceRefresh bool) ([]models.Task, error) {
cacheKey := store.CacheKeyTodoistTasks
- syncService := "todoist"
// Check cache validity
if !forceRefresh {
@@ -340,22 +339,9 @@ func (h *Handler) fetchTasks(ctx context.Context, forceRefresh bool) ([]models.T
}
}
- // Get stored sync token (empty string means full sync)
- syncToken, err := h.store.GetSyncToken(syncService)
+ tasks, err := h.todoistClient.GetTasks(ctx)
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
+ // Fall back to cached data if available
cachedTasks, cacheErr := h.store.GetTasks()
if cacheErr == nil && len(cachedTasks) > 0 {
return cachedTasks, nil
@@ -363,49 +349,15 @@ func (h *Handler) fetchTasks(ctx context.Context, forceRefresh bool) ([]models.T
return nil, 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, _ := api.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)
+ if err := h.store.SaveTasks(tasks); err != nil {
+ log.Printf("Failed to save tasks to cache: %v", err)
}
- // Update cache metadata
if err := h.store.UpdateCacheMetadata(cacheKey, h.config.CacheTTLMinutes); err != nil {
log.Printf("Failed to update cache metadata: %v", err)
}
- return h.store.GetTasks()
+ return tasks, nil
}
// fetchMeals fetches meals from cache or API
@@ -1022,7 +974,7 @@ type CombinedMeal struct {
// HandleTabMeals renders the Meals tab (PlanToEat)
func (h *Handler) HandleTabMeals(w http.ResponseWriter, r *http.Request) {
- startDate := config.Now()
+ startDate := config.Today()
endDate := startDate.AddDate(0, 0, 7)
meals, err := h.store.GetMeals(startDate, endDate)
diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go
index 793ccdd..0d097c8 100644
--- a/internal/handlers/handlers_test.go
+++ b/internal/handlers/handlers_test.go
@@ -15,7 +15,6 @@ import (
"github.com/go-chi/chi/v5"
- "task-dashboard/internal/api"
"task-dashboard/internal/config"
"task-dashboard/internal/models"
"task-dashboard/internal/store"
@@ -118,31 +117,6 @@ func (m *mockTodoistClient) ReopenTask(ctx context.Context, taskID string) error
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 {
@@ -2236,210 +2210,104 @@ func TestHandleTimeline_InvalidParams(t *testing.T) {
}
}
-// syncAwareMockTodoist records the sync token passed to Sync and returns a configurable response.
-type syncAwareMockTodoist struct {
- mockTodoistClient
- syncResponse *api.TodoistSyncResponse
- receivedTokens []string // tracks tokens passed to Sync
-}
-
-func (m *syncAwareMockTodoist) Sync(ctx context.Context, syncToken string) (*api.TodoistSyncResponse, error) {
- m.receivedTokens = append(m.receivedTokens, syncToken)
- if m.err != nil {
- return nil, m.err
- }
- return m.syncResponse, nil
-}
-
-func TestFetchTasks_IncrementalSync_UpsertsActiveTasks(t *testing.T) {
+func TestFetchTasks_FetchesAndSavesTasks(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
- // Seed DB with an existing task (simulating previous full sync)
- existingTask := models.Task{ID: "existing-1", Content: "Old task", Priority: 1}
- if err := h.store.SaveTasks([]models.Task{existingTask}); err != nil {
- t.Fatalf("Failed to seed task: %v", err)
- }
- // Set a sync token so fetchTasks uses incremental sync
- if err := h.store.SetSyncToken("todoist", "previous-token"); err != nil {
- t.Fatalf("Failed to set sync token: %v", err)
- }
-
- mock := &syncAwareMockTodoist{
- syncResponse: &api.TodoistSyncResponse{
- SyncToken: "new-token-1",
- FullSync: false,
- Items: []api.SyncItemResponse{
- {ID: "new-1", Content: "New task", Priority: 2},
- {ID: "existing-1", Content: "Updated task", Priority: 3},
- },
- Projects: []api.SyncProjectResponse{},
+ h.todoistClient = &mockTodoistClient{
+ tasks: []models.Task{
+ {ID: "t1", Content: "Task one", Priority: 1},
+ {ID: "t2", Content: "Task two", Priority: 2},
},
}
- h.todoistClient = mock
tasks, err := h.fetchTasks(context.Background(), false)
if err != nil {
t.Fatalf("fetchTasks returned error: %v", err)
}
-
- // Should have 2 tasks: the upserted existing-1 (updated) and new-1
if len(tasks) != 2 {
t.Fatalf("Expected 2 tasks, got %d", len(tasks))
}
- // Verify the existing task was updated
- taskMap := make(map[string]models.Task)
- for _, task := range tasks {
- taskMap[task.ID] = task
- }
- if taskMap["existing-1"].Content != "Updated task" {
- t.Errorf("Expected existing-1 content 'Updated task', got %q", taskMap["existing-1"].Content)
+ // Verify tasks are persisted in the store
+ stored, err := h.store.GetTasks()
+ if err != nil {
+ t.Fatalf("Failed to get tasks from store: %v", err)
}
- if taskMap["new-1"].Content != "New task" {
- t.Errorf("Expected new-1 content 'New task', got %q", taskMap["new-1"].Content)
+ if len(stored) != 2 {
+ t.Errorf("Expected 2 stored tasks, got %d", len(stored))
}
}
-func TestFetchTasks_IncrementalSync_DeletesCompletedAndDeletedTasks(t *testing.T) {
+func TestFetchTasks_ReturnsCachedTasksWhenValid(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
- // Seed DB with tasks
- seeds := []models.Task{
- {ID: "keep-1", Content: "Keep me"},
- {ID: "complete-1", Content: "Will be completed"},
- {ID: "delete-1", Content: "Will be deleted"},
- }
- if err := h.store.SaveTasks(seeds); err != nil {
+ // Seed cache with tasks and mark it valid
+ cached := []models.Task{{ID: "cached-1", Content: "Cached task"}}
+ if err := h.store.SaveTasks(cached); err != nil {
t.Fatalf("Failed to seed tasks: %v", err)
}
- if err := h.store.SetSyncToken("todoist", "prev-token"); err != nil {
- t.Fatalf("Failed to set sync token: %v", err)
+ if err := h.store.UpdateCacheMetadata(store.CacheKeyTodoistTasks, 60); err != nil {
+ t.Fatalf("Failed to set cache metadata: %v", err)
}
- mock := &syncAwareMockTodoist{
- syncResponse: &api.TodoistSyncResponse{
- SyncToken: "new-token-2",
- FullSync: false,
- Items: []api.SyncItemResponse{
- {ID: "complete-1", Content: "Will be completed", IsCompleted: true},
- {ID: "delete-1", Content: "Will be deleted", IsDeleted: true},
- },
- Projects: []api.SyncProjectResponse{},
- },
+ // API would return different tasks — should not be called
+ h.todoistClient = &mockTodoistClient{
+ tasks: []models.Task{{ID: "api-1", Content: "API task"}},
}
- h.todoistClient = mock
tasks, err := h.fetchTasks(context.Background(), false)
if err != nil {
t.Fatalf("fetchTasks returned error: %v", err)
}
-
- // Only keep-1 should remain
- if len(tasks) != 1 {
- t.Fatalf("Expected 1 task, got %d: %+v", len(tasks), tasks)
- }
- if tasks[0].ID != "keep-1" {
- t.Errorf("Expected remaining task ID 'keep-1', got %q", tasks[0].ID)
+ if len(tasks) != 1 || tasks[0].ID != "cached-1" {
+ t.Errorf("Expected cached task, got %+v", tasks)
}
}
-func TestFetchTasks_IncrementalSync_StoresNewSyncToken(t *testing.T) {
+func TestFetchTasks_ForceRefresh_BypassesCache(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
- if err := h.store.SetSyncToken("todoist", "old-token"); err != nil {
- t.Fatalf("Failed to set sync token: %v", err)
- }
-
- mock := &syncAwareMockTodoist{
- syncResponse: &api.TodoistSyncResponse{
- SyncToken: "brand-new-token",
- FullSync: false,
- Items: []api.SyncItemResponse{},
- Projects: []api.SyncProjectResponse{},
- },
- }
- h.todoistClient = mock
-
- _, err := h.fetchTasks(context.Background(), false)
- if err != nil {
- t.Fatalf("fetchTasks returned error: %v", err)
- }
-
- // Verify the new sync token was stored
- token, err := h.store.GetSyncToken("todoist")
- if err != nil {
- t.Fatalf("Failed to get sync token: %v", err)
+ // Seed cache and mark it valid
+ if err := h.store.SaveTasks([]models.Task{{ID: "old-1", Content: "Old"}}); err != nil {
+ t.Fatalf("Failed to seed tasks: %v", err)
}
- if token != "brand-new-token" {
- t.Errorf("Expected sync token 'brand-new-token', got %q", token)
+ if err := h.store.UpdateCacheMetadata(store.CacheKeyTodoistTasks, 60); err != nil {
+ t.Fatalf("Failed to set cache metadata: %v", err)
}
-}
-
-func TestFetchTasks_IncrementalSync_UsesSavedSyncToken(t *testing.T) {
- h, cleanup := setupTestHandler(t)
- defer cleanup()
- // Set a known sync token
- if err := h.store.SetSyncToken("todoist", "my-saved-token"); err != nil {
- t.Fatalf("Failed to set sync token: %v", err)
+ h.todoistClient = &mockTodoistClient{
+ tasks: []models.Task{{ID: "fresh-1", Content: "Fresh"}},
}
- mock := &syncAwareMockTodoist{
- syncResponse: &api.TodoistSyncResponse{
- SyncToken: "next-token",
- FullSync: false,
- Items: []api.SyncItemResponse{},
- Projects: []api.SyncProjectResponse{},
- },
- }
- h.todoistClient = mock
-
- _, err := h.fetchTasks(context.Background(), false)
+ tasks, err := h.fetchTasks(context.Background(), true)
if err != nil {
t.Fatalf("fetchTasks returned error: %v", err)
}
-
- // Verify the saved token was passed to Sync
- if len(mock.receivedTokens) != 1 {
- t.Fatalf("Expected 1 Sync call, got %d", len(mock.receivedTokens))
- }
- if mock.receivedTokens[0] != "my-saved-token" {
- t.Errorf("Expected Sync to receive token 'my-saved-token', got %q", mock.receivedTokens[0])
+ if len(tasks) != 1 || tasks[0].ID != "fresh-1" {
+ t.Errorf("Expected fresh task from API, got %+v", tasks)
}
}
-func TestFetchTasks_ForceRefresh_ClearsSyncToken(t *testing.T) {
+func TestFetchTasks_FallsBackToCacheOnAPIError(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
- if err := h.store.SetSyncToken("todoist", "existing-token"); err != nil {
- t.Fatalf("Failed to set sync token: %v", err)
+ // Seed stale cache
+ if err := h.store.SaveTasks([]models.Task{{ID: "stale-1", Content: "Stale"}}); err != nil {
+ t.Fatalf("Failed to seed tasks: %v", err)
}
- mock := &syncAwareMockTodoist{
- syncResponse: &api.TodoistSyncResponse{
- SyncToken: "fresh-token",
- FullSync: true,
- Items: []api.SyncItemResponse{},
- Projects: []api.SyncProjectResponse{},
- },
- }
- h.todoistClient = mock
+ h.todoistClient = &mockTodoistClient{err: fmt.Errorf("API error")}
- _, err := h.fetchTasks(context.Background(), true)
+ tasks, err := h.fetchTasks(context.Background(), false)
if err != nil {
- t.Fatalf("fetchTasks returned error: %v", err)
- }
-
- // forceRefresh should send empty token (full sync)
- if len(mock.receivedTokens) != 1 {
- t.Fatalf("Expected 1 Sync call, got %d", len(mock.receivedTokens))
+ t.Fatalf("Expected fallback to cache, got error: %v", err)
}
- if mock.receivedTokens[0] != "" {
- t.Errorf("Expected empty sync token for forceRefresh, got %q", mock.receivedTokens[0])
+ if len(tasks) != 1 || tasks[0].ID != "stale-1" {
+ t.Errorf("Expected stale cached task as fallback, got %+v", tasks)
}
}
diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go
index 33edbf2..166cd63 100644
--- a/internal/store/sqlite.go
+++ b/internal/store/sqlite.go
@@ -1229,7 +1229,7 @@ func (s *Store) GetCompletedTasks(limit int) ([]models.CompletedTask, error) {
for rows.Next() {
var task models.CompletedTask
var dueDate sql.NullString
- var completedAt string
+ var completedAt time.Time
if err := rows.Scan(&task.ID, &task.Source, &task.SourceID, &task.Title, &dueDate, &completedAt); err != nil {
return nil, err
@@ -1240,9 +1240,7 @@ func (s *Store) GetCompletedTasks(limit int) ([]models.CompletedTask, error) {
task.DueDate = &t
}
}
- if t, err := time.Parse("2006-01-02 15:04:05", completedAt); err == nil {
- task.CompletedAt = t
- }
+ task.CompletedAt = completedAt
tasks = append(tasks, task)
}