From 465093343ddd398ce5f6377fc9c472d8251c618b Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Fri, 23 Jan 2026 21:37:18 -1000 Subject: 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 --- internal/api/todoist.go | 264 +++++++++--------------------------------------- 1 file changed, 47 insertions(+), 217 deletions(-) (limited to 'internal/api/todoist.go') 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 } -- cgit v1.2.3