summaryrefslogtreecommitdiff
path: root/internal/api/todoist.go
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-01-23 21:37:18 -1000
committerPeter Stone <thepeterstone@gmail.com>2026-01-23 21:37:18 -1000
commit465093343ddd398ce5f6377fc9c472d8251c618b (patch)
treed333a2f1c8879f7b114817e929c95e9fcf5f4c3b /internal/api/todoist.go
parente23c85577cbb0eac8b847dd989072698ff4e7a30 (diff)
Refactor: reduce code duplication with shared abstractions
- Add BaseClient HTTP abstraction (internal/api/http.go) to eliminate duplicated HTTP boilerplate across Todoist, Trello, and PlanToEat clients - Add response helpers (internal/handlers/response.go) for JSON/HTML responses - Add generic cache wrapper (internal/handlers/cache.go) using Go generics - Consolidate HandleCompleteAtom/HandleUncompleteAtom into handleAtomToggle - Merge TabsHandler into Handler, delete tabs.go - Extract sortTasksByUrgency and filterAndSortTrelloTasks helpers - Update tests to work with new BaseClient structure Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'internal/api/todoist.go')
-rw-r--r--internal/api/todoist.go264
1 files changed, 47 insertions, 217 deletions
diff --git a/internal/api/todoist.go b/internal/api/todoist.go
index b3d4579..6c998cf 100644
--- a/internal/api/todoist.go
+++ b/internal/api/todoist.go
@@ -1,12 +1,8 @@
package api
import (
- "bytes"
"context"
- "encoding/json"
"fmt"
- "io"
- "net/http"
"time"
"task-dashboard/internal/models"
@@ -19,22 +15,24 @@ const (
// TodoistClient handles interactions with the Todoist API
type TodoistClient struct {
+ BaseClient
+ syncClient BaseClient
apiKey string
- baseURL string
- httpClient *http.Client
}
// NewTodoistClient creates a new Todoist API client
func NewTodoistClient(apiKey string) *TodoistClient {
return &TodoistClient{
- apiKey: apiKey,
- baseURL: todoistBaseURL,
- httpClient: &http.Client{
- Timeout: 15 * time.Second,
- },
+ BaseClient: NewBaseClient(todoistBaseURL),
+ syncClient: NewBaseClient(todoistSyncBaseURL),
+ apiKey: apiKey,
}
}
+func (c *TodoistClient) authHeaders() map[string]string {
+ return map[string]string{"Authorization": "Bearer " + c.apiKey}
+}
+
// todoistTaskResponse represents the API response structure
type todoistTaskResponse struct {
ID string `json:"id"`
@@ -93,34 +91,15 @@ type SyncProjectResponse struct {
// GetTasks fetches all active tasks from Todoist
func (c *TodoistClient) GetTasks(ctx context.Context) ([]models.Task, error) {
- req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/tasks", nil)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- req.Header.Set("Authorization", "Bearer "+c.apiKey)
-
- resp, err := c.httpClient.Do(req)
- if err != nil {
- return nil, fmt.Errorf("failed to fetch tasks: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(resp.Body)
- return nil, fmt.Errorf("todoist API error (status %d): %s", resp.StatusCode, string(body))
- }
-
var apiTasks []todoistTaskResponse
- if err := json.NewDecoder(resp.Body).Decode(&apiTasks); err != nil {
- return nil, fmt.Errorf("failed to decode response: %w", err)
+ if err := c.Get(ctx, "/tasks", c.authHeaders(), &apiTasks); err != nil {
+ return nil, fmt.Errorf("failed to fetch tasks: %w", err)
}
// Fetch projects to get project names
projects, err := c.GetProjects(ctx)
projectMap := make(map[string]string)
if err == nil {
- // Build map of project ID to name
for _, proj := range projects {
projectMap[proj.ID] = proj.Name
}
@@ -141,24 +120,11 @@ func (c *TodoistClient) GetTasks(ctx context.Context) ([]models.Task, error) {
URL: apiTask.URL,
}
- // Parse created_at
if createdAt, err := time.Parse(time.RFC3339, apiTask.CreatedAt); err == nil {
task.CreatedAt = createdAt
}
- // Parse due date
- if apiTask.Due != nil {
- var dueDate time.Time
- if apiTask.Due.Datetime != "" {
- dueDate, err = time.Parse(time.RFC3339, apiTask.Due.Datetime)
- } else if apiTask.Due.Date != "" {
- dueDate, err = time.Parse("2006-01-02", apiTask.Due.Date)
- }
- if err == nil {
- task.DueDate = &dueDate
- }
- }
-
+ task.DueDate = parseDueDate(apiTask.Due)
tasks = append(tasks, task)
}
@@ -167,30 +133,11 @@ func (c *TodoistClient) GetTasks(ctx context.Context) ([]models.Task, error) {
// GetProjects fetches all projects
func (c *TodoistClient) GetProjects(ctx context.Context) ([]models.Project, error) {
- req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/projects", nil)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- req.Header.Set("Authorization", "Bearer "+c.apiKey)
-
- resp, err := c.httpClient.Do(req)
- if err != nil {
- return nil, fmt.Errorf("failed to fetch projects: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(resp.Body)
- return nil, fmt.Errorf("todoist API error (status %d): %s", resp.StatusCode, string(body))
- }
-
var apiProjects []todoistProjectResponse
- if err := json.NewDecoder(resp.Body).Decode(&apiProjects); err != nil {
- return nil, fmt.Errorf("failed to decode response: %w", err)
+ if err := c.Get(ctx, "/projects", c.authHeaders(), &apiProjects); err != nil {
+ return nil, fmt.Errorf("failed to fetch projects: %w", err)
}
- // Convert to model
projects := make([]models.Project, 0, len(apiProjects))
for _, apiProj := range apiProjects {
projects = append(projects, models.Project{
@@ -203,46 +150,19 @@ func (c *TodoistClient) GetProjects(ctx context.Context) ([]models.Project, erro
}
// Sync performs an incremental sync using the Sync API v9
-// If syncToken is empty or "*", a full sync is performed
-// Returns the new sync token and the sync response
func (c *TodoistClient) Sync(ctx context.Context, syncToken string) (*TodoistSyncResponse, error) {
if syncToken == "" {
syncToken = "*" // Full sync
}
- // Prepare sync request
payload := map[string]interface{}{
"sync_token": syncToken,
"resource_types": []string{"items", "projects"},
}
- jsonData, err := json.Marshal(payload)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal sync request: %w", err)
- }
-
- req, err := http.NewRequestWithContext(ctx, "POST", todoistSyncBaseURL+"/sync", bytes.NewBuffer(jsonData))
- if err != nil {
- return nil, fmt.Errorf("failed to create sync request: %w", err)
- }
-
- req.Header.Set("Authorization", "Bearer "+c.apiKey)
- req.Header.Set("Content-Type", "application/json")
-
- resp, err := c.httpClient.Do(req)
- if err != nil {
- return nil, fmt.Errorf("failed to perform sync: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(resp.Body)
- return nil, fmt.Errorf("todoist sync API error (status %d): %s", resp.StatusCode, string(body))
- }
-
var syncResp TodoistSyncResponse
- if err := json.NewDecoder(resp.Body).Decode(&syncResp); err != nil {
- return nil, fmt.Errorf("failed to decode sync response: %w", err)
+ 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
@@ -252,7 +172,6 @@ func (c *TodoistClient) Sync(ctx context.Context, syncToken string) (*TodoistSyn
func ConvertSyncItemsToTasks(items []SyncItemResponse, projectMap map[string]string) []models.Task {
tasks := make([]models.Task, 0, len(items))
for _, item := range items {
- // Skip completed or deleted items
if item.IsCompleted || item.IsDeleted {
continue
}
@@ -269,27 +188,13 @@ func ConvertSyncItemsToTasks(items []SyncItemResponse, projectMap map[string]str
URL: fmt.Sprintf("https://todoist.com/app/task/%s", item.ID),
}
- // Parse added_at
if item.AddedAt != "" {
if createdAt, err := time.Parse(time.RFC3339, item.AddedAt); err == nil {
task.CreatedAt = createdAt
}
}
- // Parse due date
- 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
- }
- }
-
+ task.DueDate = parseDueDate(item.Due)
tasks = append(tasks, task)
}
return tasks
@@ -308,55 +213,22 @@ func BuildProjectMapFromSync(projects []SyncProjectResponse) map[string]string {
// 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) {
- // Prepare request payload
- payload := map[string]interface{}{
- "content": content,
- }
-
+ payload := map[string]interface{}{"content": content}
if projectID != "" {
payload["project_id"] = projectID
}
-
if dueDate != nil {
payload["due_date"] = dueDate.Format("2006-01-02")
}
-
if priority > 0 {
payload["priority"] = priority
}
- jsonData, err := json.Marshal(payload)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal request: %w", err)
- }
-
- // Create POST request
- req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/tasks", bytes.NewBuffer(jsonData))
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
- req.Header.Set("Authorization", "Bearer "+c.apiKey)
- req.Header.Set("Content-Type", "application/json")
-
- // Execute request
- resp, err := c.httpClient.Do(req)
- if err != nil {
- return nil, fmt.Errorf("failed to create task: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(resp.Body)
- return nil, fmt.Errorf("todoist API error (status %d): %s", resp.StatusCode, string(body))
- }
-
- // Decode response
var apiTask todoistTaskResponse
- if err := json.NewDecoder(resp.Body).Decode(&apiTask); err != nil {
- return nil, fmt.Errorf("failed to decode response: %w", err)
+ if err := c.Post(ctx, "/tasks", c.authHeaders(), payload, &apiTask); err != nil {
+ return nil, fmt.Errorf("failed to create task: %w", err)
}
- // Convert to our model
task := &models.Task{
ID: apiTask.ID,
Content: apiTask.Content,
@@ -368,100 +240,58 @@ func (c *TodoistClient) CreateTask(ctx context.Context, content, projectID strin
URL: apiTask.URL,
}
- // Parse created_at
if createdAt, err := time.Parse(time.RFC3339, apiTask.CreatedAt); err == nil {
task.CreatedAt = createdAt
}
- // Parse due date
- if apiTask.Due != nil {
- var taskDueDate time.Time
- if apiTask.Due.Datetime != "" {
- taskDueDate, err = time.Parse(time.RFC3339, apiTask.Due.Datetime)
- } else if apiTask.Due.Date != "" {
- taskDueDate, err = time.Parse("2006-01-02", apiTask.Due.Date)
- }
- if err == nil {
- task.DueDate = &taskDueDate
- }
- }
-
+ task.DueDate = parseDueDate(apiTask.Due)
return task, nil
}
// UpdateTask updates a task with the specified changes
func (c *TodoistClient) UpdateTask(ctx context.Context, taskID string, updates map[string]interface{}) error {
- jsonData, err := json.Marshal(updates)
- if err != nil {
- return fmt.Errorf("failed to marshal updates: %w", err)
- }
-
- url := fmt.Sprintf("%s/tasks/%s", c.baseURL, taskID)
- req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
- if err != nil {
- return fmt.Errorf("failed to create request: %w", err)
- }
- req.Header.Set("Authorization", "Bearer "+c.apiKey)
- req.Header.Set("Content-Type", "application/json")
-
- resp, err := c.httpClient.Do(req)
- if err != nil {
+ path := fmt.Sprintf("/tasks/%s", taskID)
+ if err := c.Post(ctx, path, c.authHeaders(), updates, nil); err != nil {
return fmt.Errorf("failed to update task: %w", err)
}
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
- body, _ := io.ReadAll(resp.Body)
- return fmt.Errorf("todoist API error (status %d): %s", resp.StatusCode, string(body))
- }
-
return nil
}
// CompleteTask marks a task as complete in Todoist
func (c *TodoistClient) CompleteTask(ctx context.Context, taskID string) error {
- // Create POST request to close endpoint
- url := fmt.Sprintf("%s/tasks/%s/close", c.baseURL, taskID)
- req, err := http.NewRequestWithContext(ctx, "POST", url, nil)
- if err != nil {
- return fmt.Errorf("failed to create request: %w", err)
- }
- req.Header.Set("Authorization", "Bearer "+c.apiKey)
-
- // Execute request
- resp, err := c.httpClient.Do(req)
- if err != nil {
+ path := fmt.Sprintf("/tasks/%s/close", taskID)
+ if err := c.PostEmpty(ctx, path, c.authHeaders()); err != nil {
return fmt.Errorf("failed to complete task: %w", err)
}
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(resp.Body)
- return fmt.Errorf("todoist API error (status %d): %s", resp.StatusCode, string(body))
- }
-
return nil
}
// ReopenTask marks a completed task as active in Todoist
func (c *TodoistClient) ReopenTask(ctx context.Context, taskID string) error {
- url := fmt.Sprintf("%s/tasks/%s/reopen", c.baseURL, taskID)
- req, err := http.NewRequestWithContext(ctx, "POST", url, nil)
- if err != nil {
- return fmt.Errorf("failed to create request: %w", err)
- }
- req.Header.Set("Authorization", "Bearer "+c.apiKey)
-
- resp, err := c.httpClient.Do(req)
- if err != nil {
+ path := fmt.Sprintf("/tasks/%s/reopen", taskID)
+ if err := c.PostEmpty(ctx, path, c.authHeaders()); err != nil {
return fmt.Errorf("failed to reopen task: %w", err)
}
- defer resp.Body.Close()
+ return nil
+}
- if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(resp.Body)
- return fmt.Errorf("todoist API error (status %d): %s", resp.StatusCode, string(body))
+// parseDueDate parses due date from API response
+func parseDueDate(due *struct {
+ Date string `json:"date"`
+ Datetime string `json:"datetime"`
+}) *time.Time {
+ if due == nil {
+ return nil
+ }
+ var dueDate time.Time
+ var err error
+ if due.Datetime != "" {
+ dueDate, err = time.Parse(time.RFC3339, due.Datetime)
+ } else if due.Date != "" {
+ dueDate, err = time.Parse("2006-01-02", due.Date)
}
-
- return nil
+ if err != nil {
+ return nil
+ }
+ return &dueDate
}