diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-01-13 14:18:24 -1000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-01-13 14:18:24 -1000 |
| commit | e107192be5efb65807c7da3b6aa99ce3555944d0 (patch) | |
| tree | 12f9a03a0586ec79c13b3461d960ccb27d0ae117 /internal/api/todoist.go | |
| parent | 2fee76ea41f37e3a068273c05a98b892ab29228c (diff) | |
Implement Todoist write operations - API layer (Part 1)
Add CreateTask and CompleteTask methods to Todoist API client:
Models:
- Add Project struct (ID, Name) to types.go
- Add Projects []Project field to DashboardData
API Interface:
- Change GetProjects signature to return []models.Project
- Ensure CreateTask and CompleteTask are defined
Todoist Client:
- Add baseURL field for testability
- Refactor GetProjects to return []models.Project
- Update GetTasks to build project map from new GetProjects
- Implement CreateTask with JSON payload support
- Implement CompleteTask using POST to /tasks/{id}/close
Tests:
- Create comprehensive todoist_test.go
- Test CreateTask, CreateTask with due date, CompleteTask
- Test error handling and GetProjects
- Update mock client in handlers tests
All tests pass. Ready for handlers and UI integration.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Diffstat (limited to 'internal/api/todoist.go')
| -rw-r--r-- | internal/api/todoist.go | 139 |
1 files changed, 122 insertions, 17 deletions
diff --git a/internal/api/todoist.go b/internal/api/todoist.go index be59e73..511753d 100644 --- a/internal/api/todoist.go +++ b/internal/api/todoist.go @@ -1,6 +1,7 @@ package api import ( + "bytes" "context" "encoding/json" "fmt" @@ -18,13 +19,15 @@ const ( // TodoistClient handles interactions with the Todoist API type TodoistClient struct { apiKey string + baseURL string httpClient *http.Client } // NewTodoistClient creates a new Todoist API client func NewTodoistClient(apiKey string) *TodoistClient { return &TodoistClient{ - apiKey: apiKey, + apiKey: apiKey, + baseURL: todoistBaseURL, httpClient: &http.Client{ Timeout: 30 * time.Second, }, @@ -55,7 +58,7 @@ type todoistProjectResponse struct { // GetTasks fetches all active tasks from Todoist func (c *TodoistClient) GetTasks(ctx context.Context) ([]models.Task, error) { - req, err := http.NewRequestWithContext(ctx, "GET", todoistBaseURL+"/tasks", nil) + req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/tasks", nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -80,9 +83,12 @@ func (c *TodoistClient) GetTasks(ctx context.Context) ([]models.Task, error) { // Fetch projects to get project names projects, err := c.GetProjects(ctx) - if err != nil { - // If we can't get projects, continue with empty project names - projects = make(map[string]string) + projectMap := make(map[string]string) + if err == nil { + // Build map of project ID to name + for _, proj := range projects { + projectMap[proj.ID] = proj.Name + } } // Convert to our model @@ -93,7 +99,7 @@ func (c *TodoistClient) GetTasks(ctx context.Context) ([]models.Task, error) { Content: apiTask.Content, Description: apiTask.Description, ProjectID: apiTask.ProjectID, - ProjectName: projects[apiTask.ProjectID], + ProjectName: projectMap[apiTask.ProjectID], Priority: apiTask.Priority, Completed: false, Labels: apiTask.Labels, @@ -124,9 +130,9 @@ func (c *TodoistClient) GetTasks(ctx context.Context) ([]models.Task, error) { return tasks, nil } -// GetProjects fetches all projects and returns a map of project ID to name -func (c *TodoistClient) GetProjects(ctx context.Context) (map[string]string, error) { - req, err := http.NewRequestWithContext(ctx, "GET", todoistBaseURL+"/projects", nil) +// 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) } @@ -149,10 +155,13 @@ func (c *TodoistClient) GetProjects(ctx context.Context) (map[string]string, err return nil, fmt.Errorf("failed to decode response: %w", err) } - // Convert to map - projects := make(map[string]string, len(apiProjects)) - for _, project := range apiProjects { - projects[project.ID] = project.Name + // Convert to model + projects := make([]models.Project, 0, len(apiProjects)) + for _, apiProj := range apiProjects { + projects = append(projects, models.Project{ + ID: apiProj.ID, + Name: apiProj.Name, + }) } return projects, nil @@ -160,12 +169,108 @@ func (c *TodoistClient) GetProjects(ctx context.Context) (map[string]string, err // 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) { - // This will be implemented in Phase 2 - return nil, fmt.Errorf("not implemented yet") + // Prepare request payload + 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) + } + + // Convert to our model + task := &models.Task{ + ID: apiTask.ID, + Content: apiTask.Content, + Description: apiTask.Description, + ProjectID: apiTask.ProjectID, + Priority: apiTask.Priority, + Completed: false, + Labels: apiTask.Labels, + 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 + } + } + + return task, nil } // CompleteTask marks a task as complete in Todoist func (c *TodoistClient) CompleteTask(ctx context.Context, taskID string) error { - // This will be implemented in Phase 2 - return fmt.Errorf("not implemented yet") + // 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 { + 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 } |
