summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--SESSION_STATE.md42
-rw-r--r--internal/api/interfaces.go2
-rw-r--r--internal/api/todoist.go139
-rw-r--r--internal/api/todoist_test.go264
-rw-r--r--internal/handlers/handlers_test.go4
-rw-r--r--internal/models/types.go7
-rw-r--r--issues/phase3_step4_todoist_write.md40
7 files changed, 460 insertions, 38 deletions
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.