summaryrefslogtreecommitdiff
path: root/internal/api/todoist.go
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-01-13 14:18:24 -1000
committerPeter Stone <thepeterstone@gmail.com>2026-01-13 14:18:24 -1000
commite107192be5efb65807c7da3b6aa99ce3555944d0 (patch)
tree12f9a03a0586ec79c13b3461d960ccb27d0ae117 /internal/api/todoist.go
parent2fee76ea41f37e3a068273c05a98b892ab29228c (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.go139
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
}