From e107192be5efb65807c7da3b6aa99ce3555944d0 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Tue, 13 Jan 2026 14:18:24 -1000 Subject: 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 --- SESSION_STATE.md | 42 +++--- internal/api/interfaces.go | 2 +- internal/api/todoist.go | 139 +++++++++++++++--- internal/api/todoist_test.go | 264 +++++++++++++++++++++++++++++++++++ internal/handlers/handlers_test.go | 4 +- internal/models/types.go | 7 + issues/phase3_step4_todoist_write.md | 40 ++++++ 7 files changed, 460 insertions(+), 38 deletions(-) create mode 100644 internal/api/todoist_test.go create mode 100644 issues/phase3_step4_todoist_write.md diff --git a/SESSION_STATE.md b/SESSION_STATE.md index 7fd2eaa..3755940 100644 --- a/SESSION_STATE.md +++ b/SESSION_STATE.md @@ -1,26 +1,32 @@ # Session State -**Current Phase:** Phase 3: Interactivity & Write Operations -**Current Step:** Step 3: Trello UI & Handlers +## Current Phase: Phase 3 - Interactivity & Write Operations -## Active Issues -* `issues/phase3_step3_trello_ui.md` (In Progress) +**Goal:** Transform the dashboard from a read-only viewer into an interactive tool. -## Completed Issues -* `issues/bug_002_tab_state.md` (Resolved) -* `issues/bug_001_template_rendering.md` (Resolved) -* `issues/phase3_step1_trello_write.md` (Resolved) -* `issues/phase3_step2_trello_lists.md` (Resolved) +### Progress +- [x] **Phase 1: Core Infrastructure** (Completed) +- [x] **Phase 2: Read-Only Dashboard** (Completed) +- [x] **Phase 2.5: Visual Overhaul** (Completed) + - [x] Glassmorphism UI +- [ ] **Phase 3: Interactivity** (In Progress) + - [x] Step 1: Trello Write Ops (Backend) + - [x] Step 2: Trello Lists Support + - [x] Step 3: Trello UI Integration + - [ ] **Step 4: Todoist Write Ops** (Active) + - [ ] Step 5: Unified Quick Add -## Recent Changes -* **Trello API:** Implemented `CreateCard` and `UpdateCard`. -* **Trello Lists:** Added `Lists` support to Board model and API. -* **UI:** Glassmorphism overhaul (Phase 2.5) completed. +## Active Task: Todoist Write Operations +We are implementing `CreateTask` and `CompleteTask` for Todoist, along with the UI integration. -## Next Steps -1. **Implement Trello UI:** Create partials and handlers for adding/completing cards. -2. **Todoist Write Ops:** Implement `CreateTask` and `CompleteTask`. -3. **Unified Quick Add:** Implement global command bar. +### Immediate Next Steps +1. Refactor Todoist client for testability. +2. Implement write operations. +3. Update handlers and UI. + +## Known Issues +- None currently. ## Context -We are turning the read-only dashboard into an interactive tool. We just finished the backend plumbing for Trello (Write Ops + List fetching). Now we are connecting it to the frontend. +- **Project Root:** `//wsl.localhost/Ubuntu-Preview/home/terst/workspace/task-dashboard` +- **Design System:** Glassmorphism (see `design_system.md`) diff --git a/internal/api/interfaces.go b/internal/api/interfaces.go index 31da0a8..db7e6c0 100644 --- a/internal/api/interfaces.go +++ b/internal/api/interfaces.go @@ -10,7 +10,7 @@ import ( // TodoistAPI defines the interface for Todoist operations type TodoistAPI interface { GetTasks(ctx context.Context) ([]models.Task, error) - GetProjects(ctx context.Context) (map[string]string, error) + GetProjects(ctx context.Context) ([]models.Project, error) CreateTask(ctx context.Context, content, projectID string, dueDate *time.Time, priority int) (*models.Task, error) CompleteTask(ctx context.Context, taskID string) error } 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 } diff --git a/internal/api/todoist_test.go b/internal/api/todoist_test.go new file mode 100644 index 0000000..56b1484 --- /dev/null +++ b/internal/api/todoist_test.go @@ -0,0 +1,264 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestTodoistClient_CreateTask(t *testing.T) { + // Mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify method and path + if r.Method != "POST" { + t.Errorf("Expected POST request, got %s", r.Method) + } + if r.URL.Path != "/tasks" { + t.Errorf("Expected path /tasks, got %s", r.URL.Path) + } + + // Verify Authorization header + if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { + t.Errorf("Expected Bearer token in Authorization header") + } + + // Parse JSON body + var payload map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Errorf("Failed to decode request body: %v", err) + } + + // Verify content + if payload["content"] != "Test Task" { + t.Errorf("Expected content=Test Task, got %v", payload["content"]) + } + + if payload["project_id"] != "project-123" { + t.Errorf("Expected project_id=project-123, got %v", payload["project_id"]) + } + + // Return mock response + response := todoistTaskResponse{ + ID: "task-456", + Content: "Test Task", + Description: "", + ProjectID: "project-123", + Priority: 1, + Labels: []string{}, + URL: "https://todoist.com/task/456", + CreatedAt: time.Now().Format(time.RFC3339), + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + // Create client with mock server URL + client := &TodoistClient{ + apiKey: "test-key", + baseURL: server.URL, + httpClient: &http.Client{}, + } + + // Test CreateTask + ctx := context.Background() + task, err := client.CreateTask(ctx, "Test Task", "project-123", nil, 0) + + if err != nil { + t.Fatalf("CreateTask failed: %v", err) + } + + // Verify response + if task.ID != "task-456" { + t.Errorf("Expected task ID task-456, got %s", task.ID) + } + if task.Content != "Test Task" { + t.Errorf("Expected task content Test Task, got %s", task.Content) + } + if task.ProjectID != "project-123" { + t.Errorf("Expected project ID project-123, got %s", task.ProjectID) + } +} + +func TestTodoistClient_CreateTask_WithDueDate(t *testing.T) { + dueDate := time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC) + + // Mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Parse JSON body + var payload map[string]interface{} + json.NewDecoder(r.Body).Decode(&payload) + + // Verify due_date + if payload["due_date"] != "2026-01-15" { + t.Errorf("Expected due_date=2026-01-15, got %v", payload["due_date"]) + } + + // Return mock response with due date + dueStruct := struct { + Date string `json:"date"` + Datetime string `json:"datetime"` + }{ + Date: "2026-01-15", + Datetime: "", + } + response := todoistTaskResponse{ + ID: "task-789", + Content: "Task with Due Date", + ProjectID: "project-456", + URL: "https://todoist.com/task/789", + CreatedAt: time.Now().Format(time.RFC3339), + Due: &dueStruct, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + // Create client + client := &TodoistClient{ + apiKey: "test-key", + baseURL: server.URL, + httpClient: &http.Client{}, + } + + // Test CreateTask with due date + ctx := context.Background() + task, err := client.CreateTask(ctx, "Task with Due Date", "project-456", &dueDate, 0) + + if err != nil { + t.Fatalf("CreateTask failed: %v", err) + } + + // Verify due date is set + if task.DueDate == nil { + t.Error("Expected due date to be set") + } else { + expectedDate := time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC) + if !task.DueDate.Equal(expectedDate) { + t.Errorf("Expected due date %v, got %v", expectedDate, *task.DueDate) + } + } +} + +func TestTodoistClient_CompleteTask(t *testing.T) { + // Mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify method and path + if r.Method != "POST" { + t.Errorf("Expected POST request, got %s", r.Method) + } + expectedPath := "/tasks/task-123/close" + if r.URL.Path != expectedPath { + t.Errorf("Expected path %s, got %s", expectedPath, r.URL.Path) + } + + // Verify Authorization header + if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { + t.Errorf("Expected Bearer token in Authorization header") + } + + // Return 204 No Content + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + // Create client + client := &TodoistClient{ + apiKey: "test-key", + baseURL: server.URL, + httpClient: &http.Client{}, + } + + // Test CompleteTask + ctx := context.Background() + err := client.CompleteTask(ctx, "task-123") + + if err != nil { + t.Fatalf("CompleteTask failed: %v", err) + } +} + +func TestTodoistClient_CompleteTask_Error(t *testing.T) { + // Mock server that returns error + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"error":"Task not found"}`)) + })) + defer server.Close() + + // Create client + client := &TodoistClient{ + apiKey: "test-key", + baseURL: server.URL, + httpClient: &http.Client{}, + } + + // Test CompleteTask with error + ctx := context.Background() + err := client.CompleteTask(ctx, "invalid-task") + + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "404") { + t.Errorf("Expected error to contain status 404, got: %v", err) + } +} + +func TestTodoistClient_GetProjects(t *testing.T) { + // Mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify method and path + if r.Method != "GET" { + t.Errorf("Expected GET request, got %s", r.Method) + } + if r.URL.Path != "/projects" { + t.Errorf("Expected path /projects, got %s", r.URL.Path) + } + + // Return mock response + response := []todoistProjectResponse{ + {ID: "proj-1", Name: "Project 1"}, + {ID: "proj-2", Name: "Project 2"}, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + // Create client + client := &TodoistClient{ + apiKey: "test-key", + baseURL: server.URL, + httpClient: &http.Client{}, + } + + // Test GetProjects + ctx := context.Background() + projects, err := client.GetProjects(ctx) + + if err != nil { + t.Fatalf("GetProjects failed: %v", err) + } + + // Verify response + if len(projects) != 2 { + t.Errorf("Expected 2 projects, got %d", len(projects)) + } + + if projects[0].ID != "proj-1" || projects[0].Name != "Project 1" { + t.Errorf("Project 1 mismatch: got ID=%s Name=%s", projects[0].ID, projects[0].Name) + } + + if projects[1].ID != "proj-2" || projects[1].Name != "Project 2" { + t.Errorf("Project 2 mismatch: got ID=%s Name=%s", projects[1].ID, projects[1].Name) + } +} diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index 13973df..ac940bb 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -70,8 +70,8 @@ func (m *mockTodoistClient) GetTasks(ctx context.Context) ([]models.Task, error) return m.tasks, nil } -func (m *mockTodoistClient) GetProjects(ctx context.Context) (map[string]string, error) { - return map[string]string{}, nil +func (m *mockTodoistClient) GetProjects(ctx context.Context) ([]models.Project, error) { + return []models.Project{}, nil } func (m *mockTodoistClient) CreateTask(ctx context.Context, content, projectID string, dueDate *time.Time, priority int) (*models.Task, error) { diff --git a/internal/models/types.go b/internal/models/types.go index 31308fc..245f25f 100644 --- a/internal/models/types.go +++ b/internal/models/types.go @@ -60,6 +60,12 @@ type Card struct { URL string `json:"url"` } +// Project represents a Todoist project +type Project struct { + ID string `json:"id"` + Name string `json:"name"` +} + // CacheMetadata tracks when data was last fetched type CacheMetadata struct { Key string `json:"key"` @@ -79,6 +85,7 @@ type DashboardData struct { Notes []Note `json:"notes"` Meals []Meal `json:"meals"` Boards []Board `json:"boards,omitempty"` + Projects []Project `json:"projects,omitempty"` LastUpdated time.Time `json:"last_updated"` Errors []string `json:"errors,omitempty"` } diff --git a/issues/phase3_step4_todoist_write.md b/issues/phase3_step4_todoist_write.md new file mode 100644 index 0000000..f68a963 --- /dev/null +++ b/issues/phase3_step4_todoist_write.md @@ -0,0 +1,40 @@ +# Phase 3 Step 4: Todoist Write Operations + +## Goal +Implement write operations for Todoist (Create Task, Complete Task) and update the UI to support them. + +## Requirements + +### Backend +1. **Models**: + * Add `Project` struct to `internal/models/types.go`. + * Add `Projects []Project` to `DashboardData`. +2. **API (`internal/api/todoist.go`)**: + * Refactor `GetProjects` to return `[]models.Project`. + * Implement `CreateTask(content, projectID string)`. + * Implement `CompleteTask(taskID string)`. + * Refactor `baseURL` to be a struct field for testability. +3. **Handlers (`internal/handlers/handlers.go`)**: + * Update `aggregateData` to fetch projects and populate `DashboardData`. + * Implement `HandleCreateTask`: + * Parse form (`content`, `project_id`). + * Call `CreateTask`. + * Return updated task list partial. + * Implement `HandleCompleteTask`: + * Call `CompleteTask`. + * Return empty string (to remove from DOM). + +### Frontend +1. **Template (`web/templates/partials/todoist-tasks.html`)**: + * Add "Quick Add" form at the top. + * Input: Task content. + * Select: Project (populated from `.Projects`). + * Update Task Items: + * Add Checkbox. + * `hx-post="/tasks/complete"`. + * `hx-target="closest .todoist-task-item"`. + * `hx-swap="outerHTML"`. + +## Verification +* Unit tests for API client. +* Manual verification of UI flows. -- cgit v1.2.3