From 945c34590677e161a711ccaff0e1077197b1b178 Mon Sep 17 00:00:00 2001 From: Doot Agent Date: Mon, 6 Jul 2026 00:00:59 +0000 Subject: feat: remove Todoist integration entirely Native tasks (native_tasks table) fully replace Todoist. All Todoist API code, store functions, handlers, routes, templates, and tests have been removed. Migration 021 drops the now-unused tasks cache table. Co-Authored-By: Claude Sonnet 4.6 --- internal/api/interfaces.go | 11 - internal/api/research_test.go | 241 ---------- internal/api/todoist.go | 256 ---------- internal/api/todoist_test.go | 399 --------------- internal/config/config.go | 3 - internal/config/config_test.go | 27 +- internal/handlers/agent.go | 38 +- internal/handlers/agent_test.go | 26 +- internal/handlers/atoms.go | 14 +- internal/handlers/handlers.go | 252 +--------- internal/handlers/handlers_test.go | 802 ++----------------------------- internal/handlers/settings.go | 25 +- internal/handlers/tab_state_test.go | 7 +- internal/handlers/timeline_logic.go | 49 +- internal/handlers/timeline_logic_test.go | 146 +----- internal/handlers/widget.go | 11 +- internal/handlers/widget_test.go | 8 +- internal/models/atom.go | 30 -- internal/models/atom_test.go | 54 --- internal/models/timeline.go | 2 +- internal/models/types.go | 6 +- internal/models/widget.go | 4 +- internal/store/native_tasks.go | 21 - internal/store/sqlite.go | 241 +--------- internal/store/sqlite_test.go | 260 +--------- 25 files changed, 125 insertions(+), 2808 deletions(-) delete mode 100644 internal/api/research_test.go delete mode 100644 internal/api/todoist.go delete mode 100644 internal/api/todoist_test.go (limited to 'internal') diff --git a/internal/api/interfaces.go b/internal/api/interfaces.go index 0bd67b6..c5e154d 100644 --- a/internal/api/interfaces.go +++ b/internal/api/interfaces.go @@ -7,16 +7,6 @@ import ( "task-dashboard/internal/models" ) -// TodoistAPI defines the interface for Todoist operations -type TodoistAPI interface { - GetTasks(ctx context.Context) ([]models.Task, error) - GetProjects(ctx context.Context) ([]models.Project, error) - CreateTask(ctx context.Context, content, projectID string, dueDate *time.Time, priority int) (*models.Task, error) - UpdateTask(ctx context.Context, taskID string, updates map[string]interface{}) error - CompleteTask(ctx context.Context, taskID string) error - ReopenTask(ctx context.Context, taskID string) error -} - // TrelloAPI defines the interface for Trello operations type TrelloAPI interface { GetBoards(ctx context.Context) ([]models.Board, error) @@ -59,7 +49,6 @@ type ClaudomatorClient interface { // Ensure concrete types implement interfaces var ( - _ TodoistAPI = (*TodoistClient)(nil) _ TrelloAPI = (*TrelloClient)(nil) _ PlanToEatAPI = (*PlanToEatClient)(nil) _ GoogleCalendarAPI = (*GoogleCalendarClient)(nil) diff --git a/internal/api/research_test.go b/internal/api/research_test.go deleted file mode 100644 index f2519e2..0000000 --- a/internal/api/research_test.go +++ /dev/null @@ -1,241 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "os" - "testing" - "time" -) - -// Research tests for API optimization -// Run with: go test -v -run TestResearch ./internal/api/ -// Requires TODOIST_API_KEY and TRELLO_API_KEY/TRELLO_TOKEN environment variables - -func TestTodoistSyncResearch(t *testing.T) { - apiKey := os.Getenv("TODOIST_API_KEY") - if apiKey == "" { - t.Skip("TODOIST_API_KEY not set, skipping research test") - } - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // Todoist Sync API endpoint - syncURL := "https://api.todoist.com/sync/v9/sync" - - // First sync with "*" to get all data - params := url.Values{} - params.Set("sync_token", "*") - params.Set("resource_types", `["items"]`) // "items" = tasks in Sync API - - req, err := http.NewRequestWithContext(ctx, "POST", syncURL, nil) - if err != nil { - t.Fatalf("Failed to create request: %v", err) - } - req.URL.RawQuery = params.Encode() - req.Header.Set("Authorization", "Bearer "+apiKey) - - client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(req) - if err != nil { - t.Fatalf("Failed to call Sync API: %v", err) - } - defer func() { _ = resp.Body.Close() }() - - body, err := io.ReadAll(resp.Body) - if err != nil { - t.Fatalf("Failed to read response: %v", err) - } - - if resp.StatusCode != http.StatusOK { - t.Fatalf("Sync API error (status %d): %s", resp.StatusCode, string(body)) - } - - // Parse response - var syncResp map[string]interface{} - if err := json.Unmarshal(body, &syncResp); err != nil { - t.Fatalf("Failed to parse response: %v", err) - } - - // Log findings - t.Logf("=== TODOIST SYNC API RESEARCH ===") - t.Logf("Response size: %d bytes", len(body)) - - if syncToken, ok := syncResp["sync_token"].(string); ok { - t.Logf("sync_token: %s... (length: %d)", syncToken[:min(20, len(syncToken))], len(syncToken)) - } - - if items, ok := syncResp["items"].([]interface{}); ok { - t.Logf("Number of items (tasks): %d", len(items)) - if len(items) > 0 { - // Show structure of first item - firstItem, _ := json.MarshalIndent(items[0], "", " ") - t.Logf("First item structure:\n%s", string(firstItem)) - } - } - - // List top-level keys - t.Logf("Top-level response keys:") - for key := range syncResp { - t.Logf(" - %s", key) - } - - // Save sync_token for incremental sync test - syncToken := syncResp["sync_token"].(string) - - // Test incremental sync (should return minimal data if nothing changed) - t.Logf("\n=== INCREMENTAL SYNC TEST ===") - params2 := url.Values{} - params2.Set("sync_token", syncToken) - params2.Set("resource_types", `["items"]`) - - req2, _ := http.NewRequestWithContext(ctx, "POST", syncURL, nil) - req2.URL.RawQuery = params2.Encode() - req2.Header.Set("Authorization", "Bearer "+apiKey) - - resp2, err := client.Do(req2) - if err != nil { - t.Fatalf("Incremental sync failed: %v", err) - } - defer func() { _ = resp2.Body.Close() }() - - body2, _ := io.ReadAll(resp2.Body) - t.Logf("Incremental response size: %d bytes (vs full: %d bytes)", len(body2), len(body)) - - var syncResp2 map[string]interface{} - _ = json.Unmarshal(body2, &syncResp2) - if items2, ok := syncResp2["items"].([]interface{}); ok { - t.Logf("Incremental items count: %d", len(items2)) - } -} - -func TestTrelloOptimizationResearch(t *testing.T) { - apiKey := os.Getenv("TRELLO_API_KEY") - token := os.Getenv("TRELLO_TOKEN") - if apiKey == "" || token == "" { - t.Skip("TRELLO_API_KEY or TRELLO_TOKEN not set, skipping research test") - } - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - client := &http.Client{Timeout: 30 * time.Second} - baseURL := "https://api.trello.com/1" - - // Test 1: Full response (current implementation) - t.Logf("=== TRELLO OPTIMIZATION RESEARCH ===") - - params := url.Values{} - params.Set("key", apiKey) - params.Set("token", token) - params.Set("filter", "open") - - start := time.Now() - req, _ := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/members/me/boards?%s", baseURL, params.Encode()), nil) - resp, err := client.Do(req) - if err != nil { - t.Fatalf("Failed to fetch boards: %v", err) - } - body, _ := io.ReadAll(resp.Body) - _ = resp.Body.Close() - fullDuration := time.Since(start) - - t.Logf("Test 1 - Full boards response:") - t.Logf(" Size: %d bytes", len(body)) - t.Logf(" Duration: %v", fullDuration) - - var boards []map[string]interface{} - _ = json.Unmarshal(body, &boards) - t.Logf(" Board count: %d", len(boards)) - if len(boards) > 0 { - t.Logf(" Fields in first board: %v", getKeys(boards[0])) - } - - // Test 2: Limited fields - params2 := url.Values{} - params2.Set("key", apiKey) - params2.Set("token", token) - params2.Set("filter", "open") - params2.Set("fields", "id,name") // Only essential fields - - start = time.Now() - req2, _ := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/members/me/boards?%s", baseURL, params2.Encode()), nil) - resp2, err := client.Do(req2) - if err != nil { - t.Fatalf("Failed to fetch limited boards: %v", err) - } - body2, _ := io.ReadAll(resp2.Body) - _ = resp2.Body.Close() - limitedDuration := time.Since(start) - - t.Logf("\nTest 2 - Limited fields (id,name):") - t.Logf(" Size: %d bytes (%.1f%% of full)", len(body2), float64(len(body2))/float64(len(body))*100) - t.Logf(" Duration: %v", limitedDuration) - - // Test 3: Batch request with cards included - if len(boards) > 0 { - boardID := boards[0]["id"].(string) - - params3 := url.Values{} - params3.Set("key", apiKey) - params3.Set("token", token) - params3.Set("cards", "visible") - params3.Set("card_fields", "id,name,idList,due,url") - params3.Set("lists", "open") - params3.Set("list_fields", "id,name") - - start = time.Now() - req3, _ := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/boards/%s?%s", baseURL, boardID, params3.Encode()), nil) - resp3, err := client.Do(req3) - if err != nil { - t.Fatalf("Failed to fetch board with cards: %v", err) - } - body3, _ := io.ReadAll(resp3.Body) - _ = resp3.Body.Close() - batchDuration := time.Since(start) - - t.Logf("\nTest 3 - Single board with cards/lists embedded:") - t.Logf(" Size: %d bytes", len(body3)) - t.Logf(" Duration: %v", batchDuration) - - var boardWithCards map[string]interface{} - _ = json.Unmarshal(body3, &boardWithCards) - if cards, ok := boardWithCards["cards"].([]interface{}); ok { - t.Logf(" Cards count: %d", len(cards)) - } - if lists, ok := boardWithCards["lists"].([]interface{}); ok { - t.Logf(" Lists count: %d", len(lists)) - } - } - - // Test 4: Check for webhooks/since parameter - t.Logf("\nTest 4 - Checking for 'since' parameter support:") - params4 := url.Values{} - params4.Set("key", apiKey) - params4.Set("token", token) - params4.Set("filter", "open") - params4.Set("since", time.Now().Add(-24*time.Hour).Format(time.RFC3339)) - - req4, _ := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/members/me/boards?%s", baseURL, params4.Encode()), nil) - resp4, err := client.Do(req4) - if err != nil { - t.Logf(" 'since' parameter: Error - %v", err) - } else { - body4, _ := io.ReadAll(resp4.Body) - _ = resp4.Body.Close() - t.Logf(" 'since' parameter: Status %d, Size %d bytes", resp4.StatusCode, len(body4)) - } -} - -func getKeys(m map[string]interface{}) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - return keys -} diff --git a/internal/api/todoist.go b/internal/api/todoist.go deleted file mode 100644 index 2454233..0000000 --- a/internal/api/todoist.go +++ /dev/null @@ -1,256 +0,0 @@ -package api - -import ( - "context" - "fmt" - "strings" - "time" - - "task-dashboard/internal/config" - "task-dashboard/internal/models" -) - -const ( - todoistBaseURL = "https://api.todoist.com/api/v1" -) - -// TodoistClient handles interactions with the Todoist API -type TodoistClient struct { - BaseClient - apiKey string -} - -// NewTodoistClient creates a new Todoist API client -func NewTodoistClient(apiKey string) *TodoistClient { - return &TodoistClient{ - BaseClient: NewBaseClient(todoistBaseURL), - 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"` - Content string `json:"content"` - Description string `json:"description"` - ProjectID string `json:"project_id"` - Priority int `json:"priority"` - Labels []string `json:"labels"` - Due *dueInfo `json:"due"` - URL string `json:"url"` - CreatedAt string `json:"created_at"` -} - -// todoistProjectResponse represents the project API response -type todoistProjectResponse struct { - ID string `json:"id"` - Name string `json:"name"` -} - - -// todoistTasksPage represents the paginated response from the Todoist REST API v1 -type todoistTasksPage struct { - Results []todoistTaskResponse `json:"results"` - NextCursor *string `json:"next_cursor"` -} - -// GetTasks fetches all active tasks from Todoist -func (c *TodoistClient) GetTasks(ctx context.Context) ([]models.Task, error) { - var allTasks []todoistTaskResponse - cursor := "" - for { - path := "/tasks" - if cursor != "" { - path = "/tasks?cursor=" + cursor - } - var page todoistTasksPage - if err := c.Get(ctx, path, c.authHeaders(), &page); err != nil { - return nil, fmt.Errorf("failed to fetch tasks: %w", err) - } - allTasks = append(allTasks, page.Results...) - if page.NextCursor == nil || *page.NextCursor == "" { - break - } - cursor = *page.NextCursor - } - apiTasks := allTasks - - // Fetch projects to get project names - projects, err := c.GetProjects(ctx) - projectMap := make(map[string]string) - if err == nil { - for _, proj := range projects { - projectMap[proj.ID] = proj.Name - } - } - - // Convert to our model - tasks := make([]models.Task, 0, len(apiTasks)) - for _, apiTask := range apiTasks { - task := models.Task{ - ID: apiTask.ID, - Content: apiTask.Content, - Description: apiTask.Description, - ProjectID: apiTask.ProjectID, - ProjectName: projectMap[apiTask.ProjectID], - Priority: apiTask.Priority, - Completed: false, - Labels: apiTask.Labels, - URL: apiTask.URL, - } - - if createdAt, err := time.Parse(time.RFC3339, apiTask.CreatedAt); err == nil { - task.CreatedAt = createdAt - } - - task.DueDate = parseDueDate(apiTask.Due) - if apiTask.Due != nil { - task.IsRecurring = apiTask.Due.IsRecurring - } - tasks = append(tasks, task) - } - - return tasks, nil -} - -// todoistProjectsPage represents the paginated response for projects -type todoistProjectsPage struct { - Results []todoistProjectResponse `json:"results"` - NextCursor *string `json:"next_cursor"` -} - -// GetProjects fetches all projects -func (c *TodoistClient) GetProjects(ctx context.Context) ([]models.Project, error) { - var allProjects []todoistProjectResponse - cursor := "" - for { - path := "/projects" - if cursor != "" { - path = "/projects?cursor=" + cursor - } - var page todoistProjectsPage - if err := c.Get(ctx, path, c.authHeaders(), &page); err != nil { - return nil, fmt.Errorf("failed to fetch projects: %w", err) - } - allProjects = append(allProjects, page.Results...) - if page.NextCursor == nil || *page.NextCursor == "" { - break - } - cursor = *page.NextCursor - } - apiProjects := allProjects - - 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 -} - -// 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) { - 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 - } - - var apiTask todoistTaskResponse - if err := c.Post(ctx, "/tasks", c.authHeaders(), payload, &apiTask); err != nil { - return nil, fmt.Errorf("failed to create task: %w", err) - } - - 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, - } - - if createdAt, err := time.Parse(time.RFC3339, apiTask.CreatedAt); err == nil { - task.CreatedAt = createdAt - } - - 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 { - 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) - } - return nil -} - -// CompleteTask marks a task as complete in Todoist -func (c *TodoistClient) CompleteTask(ctx context.Context, taskID string) error { - 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) - } - return nil -} - -// ReopenTask marks a completed task as active in Todoist -func (c *TodoistClient) ReopenTask(ctx context.Context, taskID string) error { - 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) - } - return nil -} - -// parseDueDate parses due date from API response -// dueInfo represents the due date structure from Todoist API -type dueInfo struct { - Date string `json:"date"` - Datetime string `json:"datetime"` - IsRecurring bool `json:"is_recurring"` -} - -func parseDueDate(due *dueInfo) *time.Time { - if due == nil { - return nil - } - var dueDate time.Time - var err error - if due.Datetime != "" { - // RFC3339 includes timezone, then convert to display timezone - dueDate, err = time.Parse(time.RFC3339, due.Datetime) - if err == nil { - dueDate = config.ToDisplayTZ(dueDate) - } - } else if due.Date != "" { - // Todoist may put a local datetime (no tz offset) in the date field - // e.g. "2026-03-22T19:00:00" for recurring tasks with a set time. - // Fall back to date-only "2006-01-02" if no T is present. - if strings.Contains(due.Date, "T") { - dueDate, err = config.ParseDateTimeInDisplayTZ("2006-01-02T15:04:05", due.Date) - } else { - dueDate, err = config.ParseDateInDisplayTZ(due.Date) - } - } - if err != nil { - return nil - } - return &dueDate -} diff --git a/internal/api/todoist_test.go b/internal/api/todoist_test.go deleted file mode 100644 index d2c8da5..0000000 --- a/internal/api/todoist_test.go +++ /dev/null @@ -1,399 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" -) - -// newTestTodoistClient creates a TodoistClient for testing with custom base URL -func newTestTodoistClient(baseURL, apiKey string) *TodoistClient { - client := NewTodoistClient(apiKey) - client.BaseURL = baseURL - return client -} - -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 := newTestTodoistClient(server.URL, "test-key") - - // 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 - 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: &dueInfo{ - Date: "2026-01-15", - Datetime: "", - }, - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(response) - })) - defer server.Close() - - // Create client - client := newTestTodoistClient(server.URL, "test-key") - - // 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 TestParseDueDate_LocalDatetimeInDateField(t *testing.T) { - // Todoist REST API v1 puts local datetime (no tz offset) in the "date" field - // when datetime is not in UTC (e.g. recurring tasks with a set time) - // e.g. due={date="2026-03-22T19:00:00" datetime="" is_recurring=true} - due := &dueInfo{ - Date: "2026-03-22T19:00:00", - Datetime: "", - IsRecurring: true, - } - result := parseDueDate(due) - if result == nil { - t.Fatal("parseDueDate returned nil for date field containing local datetime — must parse YYYY-MM-DDTHH:MM:SS format") - } - if result.Hour() != 19 || result.Minute() != 0 { - t.Errorf("Expected 19:00, got %02d:%02d", result.Hour(), result.Minute()) - } -} - -func TestParseDueDate_MicrosecondDatetime(t *testing.T) { - // Todoist REST API v1 returns datetime with microseconds: "2023-01-15T10:00:00.000000Z" - // time.RFC3339 cannot parse fractional seconds — parseDueDate must use RFC3339Nano - due := &dueInfo{ - Date: "2023-01-15", - Datetime: "2023-01-15T10:00:00.000000Z", - IsRecurring: true, - } - result := parseDueDate(due) - if result == nil { - t.Fatal("parseDueDate returned nil for datetime with microseconds — RFC3339Nano required") - } - if result.Hour() != 10 || result.Minute() != 0 { - t.Errorf("Expected 10:00, got %02d:%02d", result.Hour(), result.Minute()) - } -} - -func TestParseDueDate_RFC3339Datetime(t *testing.T) { - // Standard RFC3339 without fractional seconds should also work - due := &dueInfo{ - Date: "2023-01-15", - Datetime: "2023-01-15T10:00:00Z", - } - result := parseDueDate(due) - if result == nil { - t.Fatal("parseDueDate returned nil for standard RFC3339 datetime") - } -} - -func TestTodoistClient_UsesAPIv1BaseURL(t *testing.T) { - client := NewTodoistClient("test-key") - const want = "https://api.todoist.com/api/v1" - if client.BaseURL != want { - t.Errorf("expected base URL %q, got %q — Todoist REST v2 is deprecated (HTTP 410)", want, client.BaseURL) - } -} - -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 := newTestTodoistClient(server.URL, "test-key") - - // 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 := newTestTodoistClient(server.URL, "test-key") - - // 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 := todoistProjectsPage{ - Results: []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 := newTestTodoistClient(server.URL, "test-key") - - // 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) - } -} - -func TestTodoistClient_GetTasks(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - t.Errorf("Expected GET, got %s", r.Method) - } - - w.Header().Set("Content-Type", "application/json") - - // GetTasks also calls GetProjects internally - if r.URL.Path == "/projects" { - response := todoistProjectsPage{ - Results: []todoistProjectResponse{ - {ID: "proj-1", Name: "Project 1"}, - }, - } - json.NewEncoder(w).Encode(response) - return - } - - if r.URL.Path == "/tasks" { - response := todoistTasksPage{ - Results: []todoistTaskResponse{ - {ID: "task-1", Content: "Task 1", ProjectID: "proj-1", CreatedAt: time.Now().Format(time.RFC3339)}, - {ID: "task-2", Content: "Task 2", ProjectID: "proj-1", CreatedAt: time.Now().Format(time.RFC3339)}, - }, - } - json.NewEncoder(w).Encode(response) - return - } - - t.Errorf("Unexpected path: %s", r.URL.Path) - })) - defer server.Close() - - client := newTestTodoistClient(server.URL, "test-key") - tasks, err := client.GetTasks(context.Background()) - if err != nil { - t.Fatalf("GetTasks failed: %v", err) - } - - if len(tasks) != 2 { - t.Errorf("Expected 2 tasks, got %d", len(tasks)) - } -} - -func TestTodoistClient_ReopenTask(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - t.Errorf("Expected POST, got %s", r.Method) - } - expectedPath := "/tasks/task-123/reopen" - if r.URL.Path != expectedPath { - t.Errorf("Expected path %s, got %s", expectedPath, r.URL.Path) - } - - w.WriteHeader(http.StatusNoContent) - })) - defer server.Close() - - client := newTestTodoistClient(server.URL, "test-key") - err := client.ReopenTask(context.Background(), "task-123") - if err != nil { - t.Fatalf("ReopenTask failed: %v", err) - } -} - -func TestTodoistClient_UpdateTask(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - t.Errorf("Expected POST, got %s", r.Method) - } - expectedPath := "/tasks/task-123" - if r.URL.Path != expectedPath { - t.Errorf("Expected path %s, got %s", expectedPath, r.URL.Path) - } - - var payload map[string]interface{} - json.NewDecoder(r.Body).Decode(&payload) - if payload["content"] != "Updated Content" { - t.Errorf("Expected content 'Updated Content', got %v", payload["content"]) - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"id": "task-123"}) - })) - defer server.Close() - - client := newTestTodoistClient(server.URL, "test-key") - err := client.UpdateTask(context.Background(), "task-123", map[string]interface{}{"content": "Updated Content"}) - if err != nil { - t.Fatalf("UpdateTask failed: %v", err) - } -} - diff --git a/internal/config/config.go b/internal/config/config.go index 6d2e2ef..f4baeef 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,7 +9,6 @@ import ( // Config holds all application configuration type Config struct { // API Keys - TodoistAPIKey string PlanToEatAPIKey string PlanToEatSession string // Session cookie for web scraping TrelloAPIKey string @@ -56,7 +55,6 @@ type Config struct { func Load() (*Config, error) { cfg := &Config{ // API Keys - TodoistAPIKey: os.Getenv("TODOIST_API_KEY"), PlanToEatAPIKey: os.Getenv("PLANTOEAT_API_KEY"), PlanToEatSession: os.Getenv("PLANTOEAT_SESSION"), TrelloAPIKey: os.Getenv("TRELLO_API_KEY"), @@ -109,7 +107,6 @@ func Load() (*Config, error) { // Validate checks that required configuration is present func (c *Config) Validate() error { - // Todoist is optional — native task management replaces it when key is absent if c.TrelloAPIKey == "" { return fmt.Errorf("TRELLO_API_KEY is required") } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0722825..b436e93 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -14,34 +14,23 @@ func TestConfigValidate(t *testing.T) { }{ { name: "valid config", - cfg: Config{ - TodoistAPIKey: "todoist-key", - TrelloAPIKey: "trello-key", - TrelloToken: "trello-token", - }, - wantErr: false, - }, - { - name: "missing todoist key", cfg: Config{ TrelloAPIKey: "trello-key", TrelloToken: "trello-token", }, - wantErr: true, + wantErr: false, }, { name: "missing trello key", cfg: Config{ - TodoistAPIKey: "todoist-key", - TrelloToken: "trello-token", + TrelloToken: "trello-token", }, wantErr: true, }, { name: "missing trello token", cfg: Config{ - TodoistAPIKey: "todoist-key", - TrelloAPIKey: "trello-key", + TrelloAPIKey: "trello-key", }, wantErr: true, }, @@ -242,14 +231,12 @@ func TestToDisplayTZ(t *testing.T) { func TestLoad(t *testing.T) { // Set up required env vars - os.Setenv("TODOIST_API_KEY", "test-todoist-key") os.Setenv("TRELLO_API_KEY", "test-trello-key") os.Setenv("TRELLO_TOKEN", "test-trello-token") os.Setenv("PORT", "9999") os.Setenv("CACHE_TTL_MINUTES", "10") os.Setenv("DEBUG", "true") defer func() { - os.Unsetenv("TODOIST_API_KEY") os.Unsetenv("TRELLO_API_KEY") os.Unsetenv("TRELLO_TOKEN") os.Unsetenv("PORT") @@ -262,9 +249,6 @@ func TestLoad(t *testing.T) { t.Fatalf("Load failed: %v", err) } - if cfg.TodoistAPIKey != "test-todoist-key" { - t.Errorf("Expected TodoistAPIKey 'test-todoist-key', got '%s'", cfg.TodoistAPIKey) - } if cfg.Port != "9999" { t.Errorf("Expected Port '9999', got '%s'", cfg.Port) } @@ -278,11 +262,9 @@ func TestLoad(t *testing.T) { func TestConfig_ClaudomatorURL_Default(t *testing.T) { os.Unsetenv("CLAUDOMATOR_URL") - os.Setenv("TODOIST_API_KEY", "test-todoist-key") os.Setenv("TRELLO_API_KEY", "test-trello-key") os.Setenv("TRELLO_TOKEN", "test-trello-token") defer func() { - os.Unsetenv("TODOIST_API_KEY") os.Unsetenv("TRELLO_API_KEY") os.Unsetenv("TRELLO_TOKEN") }() @@ -298,12 +280,10 @@ func TestConfig_ClaudomatorURL_Default(t *testing.T) { func TestConfig_ClaudomatorURL_EnvOverride(t *testing.T) { os.Setenv("CLAUDOMATOR_URL", "http://1.2.3.4:9000") - os.Setenv("TODOIST_API_KEY", "test-todoist-key") os.Setenv("TRELLO_API_KEY", "test-trello-key") os.Setenv("TRELLO_TOKEN", "test-trello-token") defer func() { os.Unsetenv("CLAUDOMATOR_URL") - os.Unsetenv("TODOIST_API_KEY") os.Unsetenv("TRELLO_API_KEY") os.Unsetenv("TRELLO_TOKEN") }() @@ -319,7 +299,6 @@ func TestConfig_ClaudomatorURL_EnvOverride(t *testing.T) { func TestLoad_ValidationError(t *testing.T) { // Clear required env vars to trigger validation error - os.Unsetenv("TODOIST_API_KEY") os.Unsetenv("TRELLO_API_KEY") os.Unsetenv("TRELLO_TOKEN") diff --git a/internal/handlers/agent.go b/internal/handlers/agent.go index 826ffd7..073084f 100644 --- a/internal/handlers/agent.go +++ b/internal/handlers/agent.go @@ -414,12 +414,6 @@ func (h *Handler) handleAgentTaskToggle(w http.ResponseWriter, r *http.Request, var err error ctx := r.Context() switch source { - case "todoist": - if complete { - err = h.todoistClient.CompleteTask(ctx, id) - } else { - err = h.todoistClient.ReopenTask(ctx, id) - } case "trello": err = h.trelloClient.UpdateCard(ctx, id, map[string]interface{}{"closed": complete}) case "gtasks": @@ -450,17 +444,11 @@ func (h *Handler) handleAgentTaskToggle(w http.ResponseWriter, r *http.Request, if complete { title, dueDate := h.getAtomDetails(id, source) _ = h.store.SaveCompletedTask(source, id, title, dueDate) - switch source { - case "todoist": - _ = h.store.DeleteTask(id) - case "trello": + if source == "trello" { _ = h.store.DeleteCard(id) } } else { - switch source { - case "todoist": - _ = h.store.InvalidateCache(store.CacheKeyTodoistTasks) - case "trello": + if source == "trello" { _ = h.store.InvalidateCache(store.CacheKeyTrelloBoards) } } @@ -485,8 +473,6 @@ func (h *Handler) HandleAgentTaskUpdateDue(w http.ResponseWriter, r *http.Reques var err error ctx := r.Context() switch source { - case "todoist": - err = h.todoistClient.UpdateTask(ctx, id, map[string]interface{}{"due_datetime": req.Due}) case "trello": err = h.trelloClient.UpdateCard(ctx, id, map[string]interface{}{"due": req.Due}) default: @@ -500,10 +486,7 @@ func (h *Handler) HandleAgentTaskUpdateDue(w http.ResponseWriter, r *http.Reques } // Invalidate cache - switch source { - case "todoist": - _ = h.store.InvalidateCache(store.CacheKeyTodoistTasks) - case "trello": + if source == "trello" { _ = h.store.InvalidateCache(store.CacheKeyTrelloBoards) } @@ -525,13 +508,6 @@ func (h *Handler) HandleAgentTaskUpdate(w http.ResponseWriter, r *http.Request) var err error ctx := r.Context() switch source { - case "todoist": - // Map generic updates to Todoist specific ones if needed - if title, ok := updates["title"].(string); ok { - updates["content"] = title - delete(updates, "title") - } - err = h.todoistClient.UpdateTask(ctx, id, updates) case "trello": if title, ok := updates["title"].(string); ok { updates["name"] = title @@ -553,10 +529,7 @@ func (h *Handler) HandleAgentTaskUpdate(w http.ResponseWriter, r *http.Request) } // Invalidate cache - switch source { - case "todoist": - _ = h.store.InvalidateCache(store.CacheKeyTodoistTasks) - case "trello": + if source == "trello" { _ = h.store.InvalidateCache(store.CacheKeyTrelloBoards) } @@ -587,9 +560,6 @@ func (h *Handler) HandleAgentTaskCreate(w http.ResponseWriter, r *http.Request) var err error ctx := r.Context() switch req.Source { - case "todoist": - _, err = h.todoistClient.CreateTask(ctx, req.Title, req.ProjectID, req.DueDate, req.Priority) - _ = h.store.InvalidateCache(store.CacheKeyTodoistTasks) case "trello": if req.ListID == "" { http.Error(w, "list_id is required for Trello", http.StatusBadRequest) diff --git a/internal/handlers/agent_test.go b/internal/handlers/agent_test.go index eab1609..2893a13 100644 --- a/internal/handlers/agent_test.go +++ b/internal/handlers/agent_test.go @@ -746,13 +746,11 @@ func TestHandleAgentTaskWriteOperations(t *testing.T) { defer cleanup() // Mock clients - mockTodoist := &mockTodoistClient{} mockTrello := &mockTrelloClient{} h := &Handler{ - store: db, - todoistClient: mockTodoist, - trelloClient: mockTrello, - config: &config.Config{}, + store: db, + trelloClient: mockTrello, + config: &config.Config{}, } // Create an approved session @@ -766,11 +764,11 @@ func TestHandleAgentTaskWriteOperations(t *testing.T) { db.CreateAgentSession(session) db.ApproveAgentSession("write-request-token", sessionToken, time.Now().Add(1*time.Hour)) - // Pre-populate store with a task to get details during completion - db.SaveTasks([]models.Task{{ID: "task123", Content: "Test Task"}}) + // Pre-populate store with a native task + _ = db.CreateNativeTask(models.Task{ID: "task123", Content: "Test Task"}) t.Run("complete task", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/agent/tasks/task123/complete?source=todoist", nil) + req := httptest.NewRequest(http.MethodPost, "/agent/tasks/task123/complete?source=doot", nil) w := httptest.NewRecorder() // Manually inject session into context as middleware would @@ -804,7 +802,7 @@ func TestHandleAgentTaskWriteOperations(t *testing.T) { t.Run("update due date", func(t *testing.T) { due := time.Now().Add(24 * time.Hour) body, _ := json.Marshal(map[string]*time.Time{"due": &due}) - req := httptest.NewRequest(http.MethodPatch, "/agent/tasks/task123/due?source=todoist", bytes.NewReader(body)) + req := httptest.NewRequest(http.MethodPatch, "/agent/tasks/task123/due?source=doot", bytes.NewReader(body)) w := httptest.NewRecorder() rctx := chi.NewRouteContext() @@ -820,7 +818,7 @@ func TestHandleAgentTaskWriteOperations(t *testing.T) { t.Run("update task details", func(t *testing.T) { body, _ := json.Marshal(map[string]string{"title": "Updated Title", "description": "New Desc"}) - req := httptest.NewRequest(http.MethodPatch, "/agent/tasks/task123?source=todoist", bytes.NewReader(body)) + req := httptest.NewRequest(http.MethodPatch, "/agent/tasks/task123?source=doot", bytes.NewReader(body)) w := httptest.NewRecorder() rctx := chi.NewRouteContext() @@ -839,17 +837,15 @@ func TestHandleAgentCreateOperations(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() - mockTodoist := &mockTodoistClient{} h := &Handler{ - store: db, - todoistClient: mockTodoist, - config: &config.Config{}, + store: db, + config: &config.Config{}, } t.Run("create task", func(t *testing.T) { body, _ := json.Marshal(map[string]string{ "title": "New Agent Task", - "source": "todoist", + "source": "doot", }) req := httptest.NewRequest(http.MethodPost, "/agent/tasks", bytes.NewReader(body)) w := httptest.NewRecorder() diff --git a/internal/handlers/atoms.go b/internal/handlers/atoms.go index 0b59060..fe092a3 100644 --- a/internal/handlers/atoms.go +++ b/internal/handlers/atoms.go @@ -12,11 +12,6 @@ import ( // BuildUnifiedAtomList creates a list of atoms from tasks, cards, and google tasks func BuildUnifiedAtomList(s *store.Store, claudomator api.ClaudomatorClient) ([]models.Atom, []models.Board, error) { - tasks, err := s.GetTasks() - if err != nil { - return nil, nil, err - } - boards, err := s.GetBoards() if err != nil { return nil, nil, err @@ -33,20 +28,13 @@ func BuildUnifiedAtomList(s *store.Store, claudomator api.ClaudomatorClient) ([] log.Printf("Warning: failed to fetch native tasks: %v", err) } - atoms := make([]models.Atom, 0, len(tasks)+len(gTasks)+len(nativeTasks)) + atoms := make([]models.Atom, 0, len(gTasks)+len(nativeTasks)) // Add native doot tasks (GetNativeTasks already filters completed=0) for _, task := range nativeTasks { atoms = append(atoms, models.NativeTaskToAtom(task)) } - // Add incomplete tasks - for _, task := range tasks { - if !task.Completed { - atoms = append(atoms, models.TaskToAtom(task)) - } - } - // Add incomplete google tasks for _, gTask := range gTasks { if !gTask.Completed { diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 6c74fff..d1a7512 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -30,7 +30,6 @@ func newID() string { // Handler holds dependencies for HTTP handlers type Handler struct { store *store.Store - todoistClient api.TodoistAPI trelloClient api.TrelloAPI planToEatClient api.PlanToEatAPI googleCalendarClient api.GoogleCalendarAPI @@ -43,7 +42,7 @@ type Handler struct { } // New creates a new Handler instance -func New(s *store.Store, todoist api.TodoistAPI, trello api.TrelloAPI, planToEat api.PlanToEatAPI, googleCalendar api.GoogleCalendarAPI, googleTasks api.GoogleTasksAPI, claudomator api.ClaudomatorClient, cfg *config.Config, buildVersion string, webAuthnEnabled bool) *Handler { +func New(s *store.Store, trello api.TrelloAPI, planToEat api.PlanToEatAPI, googleCalendar api.GoogleCalendarAPI, googleTasks api.GoogleTasksAPI, claudomator api.ClaudomatorClient, cfg *config.Config, buildVersion string, webAuthnEnabled bool) *Handler { // Template functions funcMap := template.FuncMap{ "subtract": func(a, b int) int { return a - b }, @@ -63,7 +62,6 @@ func New(s *store.Store, todoist api.TodoistAPI, trello api.TrelloAPI, planToEat return &Handler{ store: s, - todoistClient: todoist, trelloClient: trello, planToEatClient: planToEat, googleCalendarClient: googleCalendar, @@ -134,16 +132,6 @@ func (h *Handler) HandleRefresh(w http.ResponseWriter, r *http.Request) { JSONResponse(w, data) } -// HandleGetTasks returns tasks as JSON -func (h *Handler) HandleGetTasks(w http.ResponseWriter, r *http.Request) { - tasks, err := h.store.GetTasks() - if err != nil { - JSONError(w, http.StatusInternalServerError, "Failed to get tasks", err) - return - } - JSONResponse(w, tasks) -} - // HandleGetMeals returns meals as JSON func (h *Handler) HandleGetMeals(w http.ResponseWriter, r *http.Request) { startDate := config.Now() @@ -221,17 +209,6 @@ func (h *Handler) aggregateData(ctx context.Context, forceRefresh bool) (*models return err }) - fetch("Todoist", func() error { - tasks, err := h.fetchTasks(ctx, forceRefresh) - if err == nil { - sortTasksByUrgency(tasks) - mu.Lock() - data.Tasks = tasks - mu.Unlock() - } - return err - }) - if h.planToEatClient != nil { fetch("PlanToEat", func() error { meals, err := h.fetchMeals(ctx, forceRefresh) @@ -270,35 +247,12 @@ func (h *Handler) aggregateData(ctx context.Context, forceRefresh bool) (*models wg.Wait() - // Populate projects from cached tasks (avoids deprecated REST API) - if projects, err := h.store.GetProjectsFromTasks(); err == nil { - data.Projects = projects - } - // Extract and sort Trello tasks data.TrelloTasks = filterAndSortTrelloTasks(data.Boards) return data, nil } -// sortTasksByUrgency sorts tasks by due date then priority -func sortTasksByUrgency(tasks []models.Task) { - sort.Slice(tasks, func(i, j int) bool { - if tasks[i].DueDate == nil && tasks[j].DueDate != nil { - return false - } - if tasks[i].DueDate != nil && tasks[j].DueDate == nil { - return true - } - if tasks[i].DueDate != nil && tasks[j].DueDate != nil { - if !tasks[i].DueDate.Equal(*tasks[j].DueDate) { - return tasks[i].DueDate.Before(*tasks[j].DueDate) - } - } - return tasks[i].Priority > tasks[j].Priority - }) -} - // filterAndSortTrelloTasks extracts task-like cards from boards func filterAndSortTrelloTasks(boards []models.Board) []models.Card { var tasks []models.Card @@ -328,39 +282,6 @@ func filterAndSortTrelloTasks(boards []models.Board) []models.Card { return tasks } -// fetchTasks fetches tasks from cache or API -func (h *Handler) fetchTasks(ctx context.Context, forceRefresh bool) ([]models.Task, error) { - cacheKey := store.CacheKeyTodoistTasks - - // Check cache validity - if !forceRefresh { - valid, err := h.store.IsCacheValid(cacheKey) - if err == nil && valid { - return h.store.GetTasks() - } - } - - tasks, err := h.todoistClient.GetTasks(ctx) - if err != nil { - // Fall back to cached data if available - cachedTasks, cacheErr := h.store.GetTasks() - if cacheErr == nil && len(cachedTasks) > 0 { - return cachedTasks, nil - } - return nil, err - } - - if err := h.store.SaveTasks(tasks); err != nil { - log.Printf("Failed to save tasks to cache: %v", err) - } - - if err := h.store.UpdateCacheMetadata(cacheKey, h.config.CacheTTLMinutes); err != nil { - log.Printf("Failed to update cache metadata: %v", err) - } - - return tasks, nil -} - // fetchMeals fetches meals from cache or API func (h *Handler) fetchMeals(ctx context.Context, forceRefresh bool) ([]models.Meal, error) { startDate := config.Now() @@ -534,52 +455,6 @@ func (h *Handler) HandleCompleteCard(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } -// HandleCreateTask creates a new Todoist task -func (h *Handler) HandleCreateTask(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - content, ok := requireFormValue(w, r, "content") - if !ok { - return - } - projectID := r.FormValue("project_id") - - if _, err := h.todoistClient.CreateTask(ctx, content, projectID, nil, 0); err != nil { - JSONError(w, http.StatusInternalServerError, "Failed to create task", err) - return - } - - tasks, err := h.fetchTasks(ctx, true) - if err != nil { - JSONError(w, http.StatusInternalServerError, "Failed to refresh tasks", err) - return - } - - projects, _ := h.store.GetProjectsFromTasks() - - data := struct { - Tasks []models.Task - Projects []models.Project - }{Tasks: tasks, Projects: projects} - - HTMLResponse(w, h.renderer, "todoist-tasks", data) -} - -// HandleCompleteTask marks a Todoist task as complete -func (h *Handler) HandleCompleteTask(w http.ResponseWriter, r *http.Request) { - taskID, ok := requireFormValue(w, r, "task_id") - if !ok { - return - } - - if err := h.todoistClient.CompleteTask(r.Context(), taskID); err != nil { - JSONError(w, http.StatusInternalServerError, "Failed to complete task", err) - return - } - - w.WriteHeader(http.StatusOK) -} - // HandleCompleteAtom handles completion of a unified task (Atom) func (h *Handler) HandleCompleteAtom(w http.ResponseWriter, r *http.Request) { h.handleAtomToggle(w, r, true) @@ -623,16 +498,6 @@ func (h *Handler) handleAtomToggle(w http.ResponseWriter, r *http.Request, compl } else { err = h.store.UncompleteNativeTask(id) } - case "todoist": - if h.todoistClient == nil { - JSONError(w, http.StatusServiceUnavailable, "Todoist not configured", nil) - return - } - if complete { - err = h.todoistClient.CompleteTask(ctx, id) - } else { - err = h.todoistClient.ReopenTask(ctx, id) - } case "trello": err = h.trelloClient.UpdateCard(ctx, id, map[string]interface{}{"closed": complete}) case "gtasks": @@ -674,8 +539,6 @@ func (h *Handler) handleAtomToggle(w http.ResponseWriter, r *http.Request, compl // Remove from local cache switch source { - case "todoist": - _ = h.store.DeleteTask(id) case "trello": _ = h.store.DeleteCard(id) case "doot": @@ -691,10 +554,7 @@ func (h *Handler) handleAtomToggle(w http.ResponseWriter, r *http.Request, compl HTMLResponse(w, h.renderer, "completed-atom", data) } else { // Invalidate cache to force refresh - switch source { - case "todoist": - _ = h.store.InvalidateCache(store.CacheKeyTodoistTasks) - case "trello": + if source == "trello" { _ = h.store.InvalidateCache(store.CacheKeyTrelloBoards) } // Don't swap empty response - just trigger refresh @@ -715,14 +575,6 @@ func (h *Handler) getAtomDetails(id, source string) (string, *time.Time) { } } } - case "todoist": - if tasks, err := h.store.GetTasks(); err == nil { - for _, t := range tasks { - if t.ID == id { - return t.Content, t.DueDate - } - } - } case "trello": if boards, err := h.store.GetBoards(); err == nil { for _, b := range boards { @@ -739,7 +591,7 @@ func (h *Handler) getAtomDetails(id, source string) (string, *time.Time) { return "Task", nil } -// HandleUnifiedAdd creates a task in Todoist or a card in Trello from the Quick Add form +// HandleUnifiedAdd creates a native task or a card in Trello from the Quick Add form func (h *Handler) HandleUnifiedAdd(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -767,25 +619,17 @@ func (h *Handler) HandleUnifiedAdd(w http.ResponseWriter, r *http.Request) { switch source { case "doot": task := models.Task{ - ID: newID(), - Content: title, - DueDate: dueDate, - Priority: 1, - Labels: nil, + ID: newID(), + Content: title, + DueDate: dueDate, + Priority: 1, + Labels: nil, } if err := h.store.CreateNativeTask(task); err != nil { JSONError(w, http.StatusInternalServerError, "Failed to create task", err) return } - case "todoist": - projectID := r.FormValue("project_id") - if _, err := h.todoistClient.CreateTask(ctx, title, projectID, dueDate, 1); err != nil { - JSONError(w, http.StatusInternalServerError, "Failed to create Todoist task", err) - return - } - _ = h.store.InvalidateCache(store.CacheKeyTodoistTasks) - case "trello": listID := r.FormValue("list_id") if listID == "" { @@ -836,15 +680,6 @@ func (h *Handler) HandleGetTaskDetail(w http.ResponseWriter, r *http.Request) { var title, description string switch source { - case "todoist": - if tasks, err := h.store.GetTasks(); err == nil { - for _, t := range tasks { - if t.ID == id { - title, description = t.Content, t.Description - break - } - } - } case "trello": if boards, err := h.store.GetBoards(); err == nil { for _, b := range boards { @@ -887,15 +722,6 @@ func (h *Handler) HandleTaskDetailPage(w http.ResponseWriter, r *http.Request) { } } } - case "todoist": - if tasks, err := h.store.GetTasks(); err == nil { - for _, t := range tasks { - if t.ID == id { - title, description = t.Content, t.Description - break - } - } - } case "trello": if boards, err := h.store.GetBoards(); err == nil { for _, b := range boards { @@ -947,8 +773,6 @@ func (h *Handler) HandleUpdateTask(w http.ResponseWriter, r *http.Request) { switch source { case "doot": err = h.store.UpdateNativeTask(id, "", description) - case "todoist": - err = h.todoistClient.UpdateTask(r.Context(), id, map[string]interface{}{"description": description}) case "trello": err = h.trelloClient.UpdateCard(r.Context(), id, map[string]interface{}{"desc": description}) default: @@ -968,18 +792,7 @@ func (h *Handler) HandleUpdateTask(w http.ResponseWriter, r *http.Request) { } } -// HandleImportFromTodoist copies the current Todoist task cache into native_tasks. -// One-time migration action. Safe to run multiple times (INSERT OR IGNORE by ID). -func (h *Handler) HandleImportFromTodoist(w http.ResponseWriter, r *http.Request) { - count, err := h.store.ImportFromTodoist() - if err != nil { - http.Error(w, "Import failed: "+err.Error(), http.StatusInternalServerError) - return - } - fmt.Fprintf(w, "Imported %d tasks from Todoist. You can now remove TODOIST_TOKEN from your .env to disable Todoist.", count) -} - -// HandleTabTasks renders the unified Tasks tab (Todoist + Trello cards with due dates + Bugs + Google Tasks) +// HandleTabTasks renders the unified Tasks tab (native tasks + Trello cards with due dates + Google Tasks) func (h *Handler) HandleTabTasks(w http.ResponseWriter, r *http.Request) { atoms, boards, err := BuildUnifiedAtomList(h.store, h.claudomatorClient) if err != nil { @@ -990,19 +803,15 @@ func (h *Handler) HandleTabTasks(w http.ResponseWriter, r *http.Request) { SortAtomsByUrgency(atoms) currentAtoms, futureAtoms := PartitionAtomsByTime(atoms) - projects, _ := h.store.GetProjectsFromTasks() - data := struct { Atoms []models.Atom FutureAtoms []models.Atom Boards []models.Board - Projects []models.Project Today string }{ Atoms: currentAtoms, FutureAtoms: futureAtoms, Boards: boards, - Projects: projects, Today: config.Now().Format("2006-01-02"), } @@ -1016,7 +825,6 @@ func (h *Handler) HandleTabPlanning(w http.ResponseWriter, r *http.Request) { in3Days := today.AddDate(0, 0, 4) boards, _ := h.store.GetBoards() - tasks, _ := h.store.GetTasks() gTasks, _ := h.store.GetGoogleTasks() events, _ := h.fetchCalendarEvents(r.Context(), false) @@ -1044,44 +852,6 @@ func (h *Handler) HandleTabPlanning(w http.ResponseWriter, r *http.Request) { } } - for _, task := range tasks { - if task.Completed || task.DueDate == nil { - continue - } - dueDate := *task.DueDate - hasTime := dueDate.Hour() != 0 || dueDate.Minute() != 0 - - if dueDate.Before(tomorrow) { - if hasTime { - scheduled = append(scheduled, ScheduledItem{ - Type: "task", - ID: task.ID, - Title: task.Content, - Start: dueDate, - URL: task.URL, - Source: "todoist", - SourceIcon: "✓", - Priority: task.Priority, - }) - } else { - atom := models.TaskToAtom(task) - atom.ComputeUIFields() - unscheduled = append(unscheduled, atom) - } - } else if dueDate.Before(in3Days) { - upcoming = append(upcoming, ScheduledItem{ - Type: "task", - ID: task.ID, - Title: task.Content, - Start: dueDate, - URL: task.URL, - Source: "todoist", - SourceIcon: "✓", - Priority: task.Priority, - }) - } - } - for _, board := range boards { for _, card := range board.Cards { if card.DueDate == nil { @@ -1171,21 +941,17 @@ func (h *Handler) HandleTabPlanning(w http.ResponseWriter, r *http.Request) { sort.Slice(unscheduled, func(i, j int) bool { return unscheduled[i].Priority > unscheduled[j].Priority }) sort.Slice(upcoming, func(i, j int) bool { return upcoming[i].Start.Before(upcoming[j].Start) }) - projects, _ := h.store.GetProjectsFromTasks() - data := struct { Scheduled []ScheduledItem Unscheduled []models.Atom Upcoming []ScheduledItem Boards []models.Board - Projects []models.Project Today string }{ Scheduled: scheduled, Unscheduled: unscheduled, Upcoming: upcoming, Boards: boards, - Projects: projects, Today: today.Format("2006-01-02"), } diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index b640c78..1f66378 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -84,40 +84,6 @@ func setupTestHandler(t *testing.T) (*Handler, func()) { return h, cleanup } -// mockTodoistClient creates a mock Todoist client for testing -type mockTodoistClient struct { - tasks []models.Task - err error -} - -func (m *mockTodoistClient) GetTasks(ctx context.Context) ([]models.Task, error) { - if m.err != nil { - return nil, m.err - } - return m.tasks, 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) { - return nil, nil -} - -func (m *mockTodoistClient) UpdateTask(ctx context.Context, taskID string, updates map[string]interface{}) error { - return m.err -} - -func (m *mockTodoistClient) CompleteTask(ctx context.Context, taskID string) error { - return nil -} - -func (m *mockTodoistClient) ReopenTask(ctx context.Context, taskID string) error { - return nil -} - - // mockTrelloClient creates a mock Trello client for testing type mockTrelloClient struct { boards []models.Board @@ -154,79 +120,6 @@ func (m *mockTrelloClient) UpdateCard(ctx context.Context, cardID string, update return nil } -// TestHandleGetTasks tests the HandleGetTasks handler -func TestHandleGetTasks(t *testing.T) { - db, cleanup := setupTestDB(t) - defer cleanup() - - // Create test tasks - testTasks := []models.Task{ - { - ID: "1", - Content: "Test task 1", - Description: "Description 1", - ProjectID: "proj1", - ProjectName: "Project 1", - Priority: 1, - Completed: false, - Labels: []string{"label1"}, - URL: "https://todoist.com/task/1", - CreatedAt: time.Now(), - }, - { - ID: "2", - Content: "Test task 2", - Description: "Description 2", - ProjectID: "proj2", - ProjectName: "Project 2", - Priority: 2, - Completed: true, - Labels: []string{"label2"}, - URL: "https://todoist.com/task/2", - CreatedAt: time.Now(), - }, - } - - // Save tasks to database - if err := db.SaveTasks(testTasks); err != nil { - t.Fatalf("Failed to save test tasks: %v", err) - } - - // Create handler with mock client - cfg := &config.Config{ - CacheTTLMinutes: 5, - } - mockTodoist := &mockTodoistClient{} - h := &Handler{ - store: db, - todoistClient: mockTodoist, - config: cfg, - } - - // Create test request - req := httptest.NewRequest("GET", "/api/tasks", nil) - w := httptest.NewRecorder() - - // Execute handler - h.HandleGetTasks(w, req) - - // Check response - if w.Code != http.StatusOK { - t.Errorf("Expected status 200, got %d", w.Code) - } - - // Parse response - var tasks []models.Task - if err := json.NewDecoder(w.Body).Decode(&tasks); err != nil { - t.Fatalf("Failed to decode response: %v", err) - } - - // Verify tasks - if len(tasks) != 2 { - t.Errorf("Expected 2 tasks, got %d", len(tasks)) - } -} - // TestHandleGetBoards tests the HandleGetBoards handler func TestHandleGetBoards(t *testing.T) { db, cleanup := setupTestDB(t) @@ -309,16 +202,6 @@ func TestHandleRefresh(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() - // Create mock clients - mockTodoist := &mockTodoistClient{ - tasks: []models.Task{ - { - ID: "1", - Content: "Test task", - }, - }, - } - mockTrello := &mockTrelloClient{ boards: []models.Board{ { @@ -333,10 +216,9 @@ func TestHandleRefresh(t *testing.T) { CacheTTLMinutes: 5, } h := &Handler{ - store: db, - todoistClient: mockTodoist, - trelloClient: mockTrello, - config: cfg, + store: db, + trelloClient: mockTrello, + config: cfg, } // Create test request @@ -397,21 +279,6 @@ func TestHandleGetMeals(t *testing.T) { } } -// mockTodoistClientWithComplete tracks CompleteTask calls -type mockTodoistClientWithComplete struct { - mockTodoistClient - completedTaskIDs []string - completeErr error -} - -func (m *mockTodoistClientWithComplete) CompleteTask(ctx context.Context, taskID string) error { - if m.completeErr != nil { - return m.completeErr - } - m.completedTaskIDs = append(m.completedTaskIDs, taskID) - return nil -} - // mockTrelloClientWithUpdate tracks UpdateCard calls type mockTrelloClientWithUpdate struct { mockTrelloClient @@ -427,138 +294,6 @@ func (m *mockTrelloClientWithUpdate) UpdateCard(ctx context.Context, cardID stri return nil } -// TestHandleCompleteAtom_Todoist tests completing a Todoist task -func TestHandleCompleteAtom_Todoist(t *testing.T) { - db, cleanup := setupTestDB(t) - defer cleanup() - - // Save a task to the cache - tasks := []models.Task{ - { - ID: "task123", - Content: "Test task", - Completed: false, - Labels: []string{}, - CreatedAt: time.Now(), - }, - } - if err := db.SaveTasks(tasks); err != nil { - t.Fatalf("Failed to save test task: %v", err) - } - - // Verify task exists in cache - cachedTasks, _ := db.GetTasks() - if len(cachedTasks) != 1 { - t.Fatalf("Expected 1 task in cache, got %d", len(cachedTasks)) - } - - // Create handler with mock client - mockTodoist := &mockTodoistClientWithComplete{} - h := &Handler{ - store: db, - todoistClient: mockTodoist, - config: &config.Config{}, - renderer: newTestRenderer(), - } - - // Create request - req := httptest.NewRequest("POST", "/complete-atom", nil) - req.Form = map[string][]string{ - "id": {"task123"}, - "source": {"todoist"}, - } - w := httptest.NewRecorder() - - // Execute handler - h.HandleCompleteAtom(w, req) - - // Check response - if w.Code != http.StatusOK { - t.Errorf("Expected status 200, got %d", w.Code) - } - - // Verify response body contains rendered completed-atom template - body := w.Body.String() - if !strings.Contains(body, "rendered:completed-atom") { - t.Errorf("Expected response body to contain 'rendered:completed-atom', got %q", body) - } - - // Verify Content-Type header - ct := w.Header().Get("Content-Type") - if ct != "text/html; charset=utf-8" { - t.Errorf("Expected Content-Type 'text/html; charset=utf-8', got %q", ct) - } - - // Verify CompleteTask was called on the API - if len(mockTodoist.completedTaskIDs) != 1 || mockTodoist.completedTaskIDs[0] != "task123" { - t.Errorf("Expected CompleteTask to be called with 'task123', got %v", mockTodoist.completedTaskIDs) - } - - // Verify task was deleted from cache - cachedTasks, _ = db.GetTasks() - if len(cachedTasks) != 0 { - t.Errorf("Expected task to be deleted from cache, but found %d tasks", len(cachedTasks)) - } -} - -func TestHandleCompleteAtom_RendersCorrectTemplateData(t *testing.T) { - db, cleanup := setupTestDB(t) - defer cleanup() - - // Seed a task with known title - tasks := []models.Task{ - {ID: "task-data-1", Content: "Buy groceries", Labels: []string{}, CreatedAt: time.Now()}, - } - if err := db.SaveTasks(tasks); err != nil { - t.Fatalf("Failed to seed: %v", err) - } - - renderer := NewMockRenderer() - h := &Handler{ - store: db, - todoistClient: &mockTodoistClientWithComplete{}, - config: &config.Config{}, - renderer: renderer, - } - - req := httptest.NewRequest("POST", "/complete-atom", nil) - req.Form = map[string][]string{"id": {"task-data-1"}, "source": {"todoist"}} - w := httptest.NewRecorder() - - h.HandleCompleteAtom(w, req) - - // Verify renderer was called with "completed-atom" and correct data - if len(renderer.Calls) != 1 { - t.Fatalf("Expected 1 render call, got %d", len(renderer.Calls)) - } - call := renderer.Calls[0] - if call.Name != "completed-atom" { - t.Errorf("Expected template 'completed-atom', got %q", call.Name) - } - - // The data should contain the task ID, source, and title - type atomData struct { - ID string - Source string - Title string - } - // Use JSON round-trip to extract fields from the anonymous struct - jsonBytes, _ := json.Marshal(call.Data) - var got atomData - if err := json.Unmarshal(jsonBytes, &got); err != nil { - t.Fatalf("Failed to unmarshal render data: %v", err) - } - if got.ID != "task-data-1" { - t.Errorf("Expected data.ID 'task-data-1', got %q", got.ID) - } - if got.Source != "todoist" { - t.Errorf("Expected data.Source 'todoist', got %q", got.Source) - } - if got.Title != "Buy groceries" { - t.Errorf("Expected data.Title 'Buy groceries', got %q", got.Title) - } -} - // TestHandleCompleteAtom_Trello tests completing a Trello card func TestHandleCompleteAtom_Trello(t *testing.T) { db, cleanup := setupTestDB(t) @@ -641,7 +376,7 @@ func TestHandleCompleteAtom_MissingParams(t *testing.T) { id string source string }{ - {"missing id", "", "todoist"}, + {"missing id", "", "doot"}, {"missing source", "task123", ""}, {"both missing", "", ""}, } @@ -688,56 +423,6 @@ func TestHandleCompleteAtom_UnknownSource(t *testing.T) { } } -// TestHandleCompleteAtom_APIError tests that API errors are handled correctly -func TestHandleCompleteAtom_APIError(t *testing.T) { - db, cleanup := setupTestDB(t) - defer cleanup() - - // Save a task to the cache - tasks := []models.Task{ - { - ID: "task123", - Content: "Test task", - Completed: false, - Labels: []string{}, - CreatedAt: time.Now(), - }, - } - if err := db.SaveTasks(tasks); err != nil { - t.Fatalf("Failed to save test task: %v", err) - } - - // Create handler with mock client that returns an error - mockTodoist := &mockTodoistClientWithComplete{ - completeErr: context.DeadlineExceeded, - } - h := &Handler{ - store: db, - todoistClient: mockTodoist, - config: &config.Config{}, - } - - req := httptest.NewRequest("POST", "/complete-atom", nil) - req.Form = map[string][]string{ - "id": {"task123"}, - "source": {"todoist"}, - } - w := httptest.NewRecorder() - - h.HandleCompleteAtom(w, req) - - // Should return 500 on API error - if w.Code != http.StatusInternalServerError { - t.Errorf("Expected status 500 on API error, got %d", w.Code) - } - - // Verify task was NOT deleted from cache (rollback behavior) - cachedTasks, _ := db.GetTasks() - if len(cachedTasks) != 1 { - t.Errorf("Task should NOT be deleted from cache on API error, found %d tasks", len(cachedTasks)) - } -} - // TestHandleShoppingModeComplete tests completing (removing) shopping items func TestHandleShoppingModeComplete(t *testing.T) { db, cleanup := setupTestDB(t) @@ -1354,12 +1039,6 @@ func TestHandleTabTasks(t *testing.T) { config: &config.Config{CacheTTLMinutes: 5}, } - // Add some tasks - tasks := []models.Task{ - {ID: "1", Content: "Task 1", Labels: []string{}, CreatedAt: time.Now()}, - } - _ = db.SaveTasks(tasks) - req := httptest.NewRequest("GET", "/tabs/tasks", nil) w := httptest.NewRecorder() @@ -1599,69 +1278,6 @@ func TestHTMLString(t *testing.T) { // Uncomplete Atom Tests // ============================================================================= -// mockTodoistClientWithReopen tracks ReopenTask calls -type mockTodoistClientWithReopen struct { - mockTodoistClient - reopenedTaskIDs []string - reopenErr error -} - -func (m *mockTodoistClientWithReopen) ReopenTask(ctx context.Context, taskID string) error { - if m.reopenErr != nil { - return m.reopenErr - } - m.reopenedTaskIDs = append(m.reopenedTaskIDs, taskID) - return nil -} - -func TestHandleUncompleteAtom_Todoist(t *testing.T) { - db, cleanup := setupTestDB(t) - defer cleanup() - - mockTodoist := &mockTodoistClientWithReopen{} - h := &Handler{ - store: db, - todoistClient: mockTodoist, - config: &config.Config{}, - renderer: newTestRenderer(), - } - - req := httptest.NewRequest("POST", "/uncomplete-atom", nil) - req.Form = map[string][]string{ - "id": {"task123"}, - "source": {"todoist"}, - } - w := httptest.NewRecorder() - - h.HandleUncompleteAtom(w, req) - - if w.Code != http.StatusOK { - t.Errorf("Expected status 200, got %d", w.Code) - } - - // Verify HX-Reswap header set to "none" - reswap := w.Header().Get("HX-Reswap") - if reswap != "none" { - t.Errorf("Expected HX-Reswap header 'none', got %q", reswap) - } - - // Verify HX-Trigger header set to "refresh-tasks" - trigger := w.Header().Get("HX-Trigger") - if trigger != "refresh-tasks" { - t.Errorf("Expected HX-Trigger header 'refresh-tasks', got %q", trigger) - } - - // Verify response body is empty (no template rendered) - body := w.Body.String() - if body != "" { - t.Errorf("Expected empty response body for uncomplete, got %q", body) - } - - if len(mockTodoist.reopenedTaskIDs) != 1 || mockTodoist.reopenedTaskIDs[0] != "task123" { - t.Errorf("Expected ReopenTask to be called with 'task123', got %v", mockTodoist.reopenedTaskIDs) - } -} - // ============================================================================= // Settings Handler Tests // ============================================================================= @@ -1769,16 +1385,6 @@ func TestHandleGetSourceOptions_MissingSource(t *testing.T) { } } -// mockTodoistClientWithProjects returns mock projects -type mockTodoistClientWithProjects struct { - mockTodoistClient - projects []models.Project -} - -func (m *mockTodoistClientWithProjects) GetProjects(ctx context.Context) ([]models.Project, error) { - return m.projects, nil -} - // mockTrelloClientWithBoards returns mock boards type mockTrelloClientWithBoards struct { mockTrelloClient @@ -1788,14 +1394,6 @@ func TestHandleSyncSources(t *testing.T) { h, cleanup := setupTestHandler(t) defer cleanup() - // Seed tasks table with project info (projects now come from store, not REST API) - tasks := []models.Task{ - {ID: "t1", Content: "Task 1", ProjectID: "proj1", ProjectName: "Project 1", Labels: []string{}}, - {ID: "t2", Content: "Task 2", ProjectID: "proj2", ProjectName: "Project 2", Labels: []string{}}, - } - _ = h.store.SaveTasks(tasks) - - mockTodoist := &mockTodoistClient{} mockTrello := &mockTrelloClientWithBoards{ mockTrelloClient: mockTrelloClient{ boards: []models.Board{ @@ -1804,7 +1402,6 @@ func TestHandleSyncSources(t *testing.T) { }, } - h.todoistClient = mockTodoist h.trelloClient = mockTrello req := httptest.NewRequest("POST", "/settings/sync", nil) @@ -1816,10 +1413,10 @@ func TestHandleSyncSources(t *testing.T) { t.Errorf("Expected status 200, got %d", w.Code) } - // Verify configs were synced from store (not REST API) - configs, _ := h.store.GetSourceConfigsBySource("todoist") - if len(configs) != 2 { - t.Errorf("Expected 2 todoist configs, got %d", len(configs)) + // Verify Trello configs were synced + configs, _ := h.store.GetSourceConfigsBySource("trello") + if len(configs) != 1 { + t.Errorf("Expected 1 trello config, got %d", len(configs)) } } @@ -1921,14 +1518,6 @@ func TestHandleTimeline_RendersDataToTemplate(t *testing.T) { h, cleanup := setupTestHandler(t) defer cleanup() - // Seed tasks with due dates in range - now := config.Now() - todayNoon := time.Date(now.Year(), now.Month(), now.Day(), 12, 0, 0, 0, now.Location()) - tasks := []models.Task{ - {ID: "t1", Content: "Today task", DueDate: &todayNoon, Labels: []string{}, CreatedAt: now}, - } - _ = h.store.SaveTasks(tasks) - req := httptest.NewRequest("GET", "/tabs/timeline", nil) w := httptest.NewRecorder() @@ -1949,14 +1538,10 @@ func TestHandleTimeline_RendersDataToTemplate(t *testing.T) { t.Errorf("Expected template 'timeline-tab', got '%s'", lastCall.Name) } - data, ok := lastCall.Data.(TimelineData) + _, ok := lastCall.Data.(TimelineData) if !ok { t.Fatalf("Expected TimelineData, got %T", lastCall.Data) } - - if len(data.TodayItems) == 0 { - t.Error("Expected TodayItems to contain at least one item, got 0") - } } // assertTemplateContains reads a template file and asserts it contains the expected string. @@ -1979,7 +1564,6 @@ func TestHandleDashboard_ContainsSettingsLink(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() - mockTodoist := &mockTodoistClient{} mockTrello := &mockTrelloClient{} // Use a renderer that outputs actual content so we can check for settings link @@ -1994,11 +1578,10 @@ func TestHandleDashboard_ContainsSettingsLink(t *testing.T) { } h := &Handler{ - store: db, - todoistClient: mockTodoist, - trelloClient: mockTrello, - config: &config.Config{CacheTTLMinutes: 5}, - renderer: renderer, + store: db, + trelloClient: mockTrello, + config: &config.Config{CacheTTLMinutes: 5}, + renderer: renderer, } req := httptest.NewRequest("GET", "/", nil) @@ -2112,17 +1695,15 @@ func TestHandleDashboard_PassesBuildVersion(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() - mockTodoist := &mockTodoistClient{} mockTrello := &mockTrelloClient{} renderer := NewMockRenderer() h := &Handler{ - store: db, - todoistClient: mockTodoist, - trelloClient: mockTrello, - config: &config.Config{CacheTTLMinutes: 5}, - renderer: renderer, - BuildVersion: "abc123", + store: db, + trelloClient: mockTrello, + config: &config.Config{CacheTTLMinutes: 5}, + renderer: renderer, + BuildVersion: "abc123", } req := httptest.NewRequest("GET", "/", nil) @@ -2210,123 +1791,20 @@ func TestHandleTimeline_InvalidParams(t *testing.T) { } } -func TestFetchTasks_FetchesAndSavesTasks(t *testing.T) { - h, cleanup := setupTestHandler(t) - defer cleanup() - - h.todoistClient = &mockTodoistClient{ - tasks: []models.Task{ - {ID: "t1", Content: "Task one", Priority: 1}, - {ID: "t2", Content: "Task two", Priority: 2}, - }, - } - - tasks, err := h.fetchTasks(context.Background(), false) - if err != nil { - t.Fatalf("fetchTasks returned error: %v", err) - } - if len(tasks) != 2 { - t.Fatalf("Expected 2 tasks, got %d", len(tasks)) - } - - // Verify tasks are persisted in the store - stored, err := h.store.GetTasks() - if err != nil { - t.Fatalf("Failed to get tasks from store: %v", err) - } - if len(stored) != 2 { - t.Errorf("Expected 2 stored tasks, got %d", len(stored)) - } -} - -func TestFetchTasks_ReturnsCachedTasksWhenValid(t *testing.T) { - h, cleanup := setupTestHandler(t) - defer cleanup() - - // Seed cache with tasks and mark it valid - cached := []models.Task{{ID: "cached-1", Content: "Cached task"}} - if err := h.store.SaveTasks(cached); err != nil { - t.Fatalf("Failed to seed tasks: %v", err) - } - if err := h.store.UpdateCacheMetadata(store.CacheKeyTodoistTasks, 60); err != nil { - t.Fatalf("Failed to set cache metadata: %v", err) - } - - // API would return different tasks — should not be called - h.todoistClient = &mockTodoistClient{ - tasks: []models.Task{{ID: "api-1", Content: "API task"}}, - } - - tasks, err := h.fetchTasks(context.Background(), false) - if err != nil { - t.Fatalf("fetchTasks returned error: %v", err) - } - if len(tasks) != 1 || tasks[0].ID != "cached-1" { - t.Errorf("Expected cached task, got %+v", tasks) - } -} - -func TestFetchTasks_ForceRefresh_BypassesCache(t *testing.T) { - h, cleanup := setupTestHandler(t) - defer cleanup() - - // Seed cache and mark it valid - if err := h.store.SaveTasks([]models.Task{{ID: "old-1", Content: "Old"}}); err != nil { - t.Fatalf("Failed to seed tasks: %v", err) - } - if err := h.store.UpdateCacheMetadata(store.CacheKeyTodoistTasks, 60); err != nil { - t.Fatalf("Failed to set cache metadata: %v", err) - } - - h.todoistClient = &mockTodoistClient{ - tasks: []models.Task{{ID: "fresh-1", Content: "Fresh"}}, - } - - tasks, err := h.fetchTasks(context.Background(), true) - if err != nil { - t.Fatalf("fetchTasks returned error: %v", err) - } - if len(tasks) != 1 || tasks[0].ID != "fresh-1" { - t.Errorf("Expected fresh task from API, got %+v", tasks) - } -} - -func TestFetchTasks_FallsBackToCacheOnAPIError(t *testing.T) { - h, cleanup := setupTestHandler(t) - defer cleanup() - - // Seed stale cache - if err := h.store.SaveTasks([]models.Task{{ID: "stale-1", Content: "Stale"}}); err != nil { - t.Fatalf("Failed to seed tasks: %v", err) - } - - h.todoistClient = &mockTodoistClient{err: fmt.Errorf("API error")} - - tasks, err := h.fetchTasks(context.Background(), false) - if err != nil { - t.Fatalf("Expected fallback to cache, got error: %v", err) - } - if len(tasks) != 1 || tasks[0].ID != "stale-1" { - t.Errorf("Expected stale cached task as fallback, got %+v", tasks) - } -} - // ============================================================================= -// Clear Cache + GetProjects removal tests +// Clear Cache tests // ============================================================================= // TestHandleClearCache verifies that POST /settings/clear-cache invalidates -// all cache keys, clears the todoist sync token, and returns 200 with HX-Trigger. +// all cache keys and returns 200 with HX-Trigger. func TestHandleClearCache(t *testing.T) { h, cleanup := setupTestHandler(t) defer cleanup() - // Seed cache metadata and sync token so we can verify they get cleared - _ = h.store.UpdateCacheMetadata("todoist_tasks", 5) + // Seed cache metadata so we can verify it gets cleared _ = h.store.UpdateCacheMetadata("trello_boards", 5) _ = h.store.UpdateCacheMetadata("plantoeat_meals", 5) _ = h.store.UpdateCacheMetadata("google_calendar", 5) - _ = h.store.SetSyncToken("todoist", "some-sync-token") req := httptest.NewRequest("POST", "/settings/clear-cache", nil) w := httptest.NewRecorder() @@ -2343,62 +1821,20 @@ func TestHandleClearCache(t *testing.T) { } // Verify all cache keys invalidated - for _, key := range []string{"todoist_tasks", "trello_boards", "plantoeat_meals", "google_calendar"} { + for _, key := range []string{"trello_boards", "plantoeat_meals", "google_calendar"} { valid, _ := h.store.IsCacheValid(key) if valid { t.Errorf("Cache key %q should be invalidated but is still valid", key) } } - - // Verify sync token cleared - token, _ := h.store.GetSyncToken("todoist") - if token != "" { - t.Errorf("Expected empty sync token after clear cache, got %q", token) - } } -// TestGetProjectsFromTasks verifies that distinct project pairs are extracted -// from the tasks table. -func TestGetProjectsFromTasks(t *testing.T) { - h, cleanup := setupTestHandler(t) - defer cleanup() - - // Seed tasks with different projects (including duplicates and empty) - tasks := []models.Task{ - {ID: "1", Content: "Task 1", ProjectID: "p1", ProjectName: "Inbox", Labels: []string{}}, - {ID: "2", Content: "Task 2", ProjectID: "p1", ProjectName: "Inbox", Labels: []string{}}, - {ID: "3", Content: "Task 3", ProjectID: "p2", ProjectName: "Work", Labels: []string{}}, - {ID: "4", Content: "Task 4", ProjectID: "", ProjectName: "", Labels: []string{}}, - } - if err := h.store.SaveTasks(tasks); err != nil { - t.Fatalf("Failed to save tasks: %v", err) - } - - projects, err := h.store.GetProjectsFromTasks() - if err != nil { - t.Fatalf("GetProjectsFromTasks returned error: %v", err) - } - - if len(projects) != 2 { - t.Fatalf("Expected 2 distinct projects, got %d: %v", len(projects), projects) - } - - // Verify both projects are present - found := map[string]bool{} - for _, p := range projects { - found[p.ID] = true - } - if !found["p1"] || !found["p2"] { - t.Errorf("Expected projects p1 and p2, got %v", projects) - } -} - -// TestInvalidateAllCaches verifies the convenience method clears all 4 keys. +// TestInvalidateAllCaches verifies the convenience method clears all cache keys. func TestInvalidateAllCaches(t *testing.T) { h, cleanup := setupTestHandler(t) defer cleanup() - keys := []string{"todoist_tasks", "trello_boards", "plantoeat_meals", "google_calendar"} + keys := []string{"trello_boards", "plantoeat_meals", "google_calendar"} for _, key := range keys { _ = h.store.UpdateCacheMetadata(key, 5) } @@ -2423,116 +1859,6 @@ func TestSettingsTemplate_HasClearCacheButton(t *testing.T) { "settings.html must contain a clear-cache button/endpoint") } -// TestAggregateData_DoesNotCallGetProjects verifies that aggregateData no longer -// calls the deprecated GetProjects REST API. -func TestAggregateData_DoesNotCallGetProjects(t *testing.T) { - h, cleanup := setupTestHandler(t) - defer cleanup() - - getProjectsCalled := false - mock := &mockTodoistClientTracksGetProjects{ - mockTodoistClient: mockTodoistClient{ - tasks: []models.Task{ - {ID: "1", Content: "Test", ProjectID: "p1", ProjectName: "Inbox", Labels: []string{}}, - }, - }, - onGetProjects: func() { getProjectsCalled = true }, - } - h.todoistClient = mock - h.trelloClient = &mockTrelloClient{boards: []models.Board{}} - - _, err := h.aggregateData(context.Background(), false) - if err != nil { - t.Fatalf("aggregateData returned error: %v", err) - } - - if getProjectsCalled { - t.Error("aggregateData should NOT call GetProjects (deprecated REST API)") - } -} - -// TestHandleCreateTask_DoesNotCallGetProjects verifies that HandleCreateTask -// populates projects from the store, not the REST API. -func TestHandleCreateTask_DoesNotCallGetProjects(t *testing.T) { - h, cleanup := setupTestHandler(t) - defer cleanup() - - // Seed tasks with project info - tasks := []models.Task{ - {ID: "1", Content: "Existing", ProjectID: "p1", ProjectName: "Inbox", Labels: []string{}}, - } - _ = h.store.SaveTasks(tasks) - - getProjectsCalled := false - mock := &mockTodoistClientTracksGetProjects{ - mockTodoistClient: mockTodoistClient{ - tasks: tasks, - }, - onGetProjects: func() { getProjectsCalled = true }, - } - h.todoistClient = mock - - body := strings.NewReader("content=New+Task&project_id=p1") - req := httptest.NewRequest("POST", "/tasks", body) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - w := httptest.NewRecorder() - - h.HandleCreateTask(w, req) - - if getProjectsCalled { - t.Error("HandleCreateTask should NOT call GetProjects (deprecated REST API)") - } -} - -// TestHandleSyncSources_UsesStoreForProjects verifies that HandleSyncSources -// reads projects from the tasks table instead of the REST API. -func TestHandleSyncSources_UsesStoreForProjects(t *testing.T) { - h, cleanup := setupTestHandler(t) - defer cleanup() - - // Seed tasks with project info - tasks := []models.Task{ - {ID: "1", Content: "Task1", ProjectID: "p1", ProjectName: "Inbox", Labels: []string{}}, - {ID: "2", Content: "Task2", ProjectID: "p2", ProjectName: "Work", Labels: []string{}}, - } - _ = h.store.SaveTasks(tasks) - - getProjectsCalled := false - mock := &mockTodoistClientTracksGetProjects{ - mockTodoistClient: mockTodoistClient{tasks: tasks}, - onGetProjects: func() { getProjectsCalled = true }, - } - h.todoistClient = mock - - req := httptest.NewRequest("POST", "/settings/sync", nil) - w := httptest.NewRecorder() - - h.HandleSyncSources(w, req) - - if getProjectsCalled { - t.Error("HandleSyncSources should NOT call GetProjects (deprecated REST API)") - } - - // Verify todoist project configs were still synced from store - configs, _ := h.store.GetSourceConfigsBySource("todoist") - if len(configs) != 2 { - t.Errorf("Expected 2 todoist configs from store, got %d", len(configs)) - } -} - -// mockTodoistClientTracksGetProjects wraps mockTodoistClient and tracks GetProjects calls. -type mockTodoistClientTracksGetProjects struct { - mockTodoistClient - onGetProjects func() -} - -func (m *mockTodoistClientTracksGetProjects) GetProjects(ctx context.Context) ([]models.Project, error) { - if m.onGetProjects != nil { - m.onGetProjects() - } - return []models.Project{}, nil -} - // --- Sync Log Tests --- // TestStore_AddAndGetSyncLog verifies that sync log entries can be added and retrieved. @@ -2678,7 +2004,7 @@ func TestHandleGetTaskDetail_RendersTemplate(t *testing.T) { h, cleanup := setupTestHandler(t) defer cleanup() - req := httptest.NewRequest("GET", "/tasks/detail?id=abc&source=todoist", nil) + req := httptest.NewRequest("GET", "/tasks/detail?id=abc&source=doot", nil) w := httptest.NewRecorder() h.HandleGetTaskDetail(w, req) @@ -2749,7 +2075,7 @@ func TestHandleSyncSources_AddsLogEntry(t *testing.T) { // HandleTabPlanning data tests // ============================================================================= -// TestHandleTabPlanning_HappyPath verifies that tasks, events, and cards are +// TestHandleTabPlanning_HappyPath verifies that cards are // placed into the correct planning sections (scheduled/unscheduled/upcoming). func TestHandleTabPlanning_HappyPath(t *testing.T) { db, cleanup := setupTestDB(t) @@ -2757,29 +2083,16 @@ func TestHandleTabPlanning_HappyPath(t *testing.T) { today := config.Today() - // Task at 10am today (has time) → goes to Scheduled - taskWithTime := today.Add(10 * time.Hour) - // Task at midnight today (no time component) → goes to Unscheduled - taskNoTime := today - // Task 2 days from now → goes to Upcoming (today+4 is the upper bound) - taskUpcoming := today.AddDate(0, 0, 2) - - tasks := []models.Task{ - {ID: "t-sched", Content: "Scheduled Task", DueDate: &taskWithTime, Labels: []string{}, CreatedAt: time.Now()}, - {ID: "t-unsched", Content: "Unscheduled Task", DueDate: &taskNoTime, Labels: []string{}, CreatedAt: time.Now()}, - {ID: "t-upcoming", Content: "Upcoming Task", DueDate: &taskUpcoming, Labels: []string{}, CreatedAt: time.Now()}, - } - if err := db.SaveTasks(tasks); err != nil { - t.Fatalf("Failed to save tasks: %v", err) - } - // Card at 2pm today → goes to Scheduled cardWithTime := today.Add(14 * time.Hour) + // Card 2 days from now → goes to Upcoming + cardUpcoming := today.AddDate(0, 0, 2) boards := []models.Board{{ ID: "board1", Name: "Board 1", Cards: []models.Card{ {ID: "c-sched", Name: "Scheduled Card", DueDate: &cardWithTime, URL: "http://trello.com/c1"}, + {ID: "c-upcoming", Name: "Upcoming Card", DueDate: &cardUpcoming, URL: "http://trello.com/c2"}, }, }} if err := db.SaveBoards(boards); err != nil { @@ -2818,65 +2131,51 @@ func TestHandleTabPlanning_HappyPath(t *testing.T) { Unscheduled []models.Atom Upcoming []ScheduledItem Boards []models.Board - Projects []models.Project Today string }) if !ok { t.Fatalf("Expected planning data struct, got %T", planningCall.Data) } - // t-sched (10am) and c-sched (2pm) should be in Scheduled - var foundTaskSched, foundCardSched bool + // c-sched (2pm) should be in Scheduled + var foundCardSched bool for _, s := range data.Scheduled { - if s.ID == "t-sched" { - foundTaskSched = true - } if s.ID == "c-sched" { foundCardSched = true } } - if !foundTaskSched { - t.Error("Expected t-sched (task with time today) in Scheduled") - } if !foundCardSched { t.Error("Expected c-sched (card with time today) in Scheduled") } - // t-unsched (midnight, no time) should be in Unscheduled - var foundUnsched bool - for _, u := range data.Unscheduled { - if u.ID == "t-unsched" { - foundUnsched = true - } - } - if !foundUnsched { - t.Error("Expected t-unsched (midnight task) in Unscheduled") - } - - // t-upcoming (2 days out) should be in Upcoming + // c-upcoming (2 days out) should be in Upcoming var foundUpcoming bool for _, u := range data.Upcoming { - if u.ID == "t-upcoming" { + if u.ID == "c-upcoming" { foundUpcoming = true } } if !foundUpcoming { - t.Error("Expected t-upcoming (task in 2 days) in Upcoming") + t.Error("Expected c-upcoming (card in 2 days) in Upcoming") } } -// TestHandleTabPlanning_TomorrowBoundary verifies that a task due exactly at +// TestHandleTabPlanning_TomorrowBoundary verifies that a card due exactly at // midnight of "tomorrow" is NOT before tomorrow, so it lands in Upcoming. func TestHandleTabPlanning_TomorrowBoundary(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() tomorrow := config.Today().AddDate(0, 0, 1) // midnight tomorrow - tasks := []models.Task{ - {ID: "t-boundary", Content: "Midnight Tomorrow", DueDate: &tomorrow, Labels: []string{}, CreatedAt: time.Now()}, - } - if err := db.SaveTasks(tasks); err != nil { - t.Fatalf("Failed to save tasks: %v", err) + boards := []models.Board{{ + ID: "board1", + Name: "Board 1", + Cards: []models.Card{ + {ID: "c-boundary", Name: "Midnight Tomorrow", DueDate: &tomorrow, URL: "http://trello.com/c1"}, + }, + }} + if err := db.SaveBoards(boards); err != nil { + t.Fatalf("Failed to save boards: %v", err) } renderer := NewMockRenderer() @@ -2911,7 +2210,6 @@ func TestHandleTabPlanning_TomorrowBoundary(t *testing.T) { Unscheduled []models.Atom Upcoming []ScheduledItem Boards []models.Board - Projects []models.Project Today string }) if !ok { @@ -2920,24 +2218,24 @@ func TestHandleTabPlanning_TomorrowBoundary(t *testing.T) { // midnight-tomorrow: !dueDate.Before(tomorrow) → not in scheduled/unscheduled for _, s := range data.Scheduled { - if s.ID == "t-boundary" { - t.Error("Midnight-tomorrow task must not appear in Scheduled") + if s.ID == "c-boundary" { + t.Error("Midnight-tomorrow card must not appear in Scheduled") } } for _, u := range data.Unscheduled { - if u.ID == "t-boundary" { - t.Error("Midnight-tomorrow task must not appear in Unscheduled") + if u.ID == "c-boundary" { + t.Error("Midnight-tomorrow card must not appear in Unscheduled") } } // dueDate.Before(in3Days=today+4) → lands in Upcoming var foundUpcoming bool for _, u := range data.Upcoming { - if u.ID == "t-boundary" { + if u.ID == "c-boundary" { foundUpcoming = true } } if !foundUpcoming { - t.Error("Expected midnight-tomorrow task in Upcoming") + t.Error("Expected midnight-tomorrow card in Upcoming") } } diff --git a/internal/handlers/settings.go b/internal/handlers/settings.go index 32e0215..dc345db 100644 --- a/internal/handlers/settings.go +++ b/internal/handlers/settings.go @@ -34,7 +34,7 @@ func (h *Handler) HandleSettingsPage(w http.ResponseWriter, r *http.Request) { WebAuthnEnabled bool }{ Configs: bySource, - Sources: []string{"trello", "todoist", "gcal", "gtasks"}, + Sources: []string{"trello", "gcal", "gtasks"}, Toggles: toggles, SyncLog: syncLog, Agents: agents, @@ -68,20 +68,6 @@ func (h *Handler) HandleSyncSources(w http.ResponseWriter, r *http.Request) { } } - // Sync Todoist projects from cached tasks (avoids deprecated REST API) - if projects, err := h.store.GetProjectsFromTasks(); err == nil && len(projects) > 0 { - var items []models.SourceConfig - for _, p := range projects { - items = append(items, models.SourceConfig{ - Source: "todoist", - ItemType: "project", - ItemID: p.ID, - ItemName: p.Name, - }) - } - _ = h.store.SyncSourceConfigs("todoist", "project", items) - } - // Sync Google Calendar calendars if h.googleCalendarClient != nil { calendars, err := h.googleCalendarClient.GetCalendarList(ctx) @@ -122,18 +108,13 @@ func (h *Handler) HandleSyncSources(w http.ResponseWriter, r *http.Request) { h.HandleSettingsPage(w, r) } -// HandleClearCache invalidates all caches and clears the Todoist sync token +// HandleClearCache invalidates all caches func (h *Handler) HandleClearCache(w http.ResponseWriter, r *http.Request) { if err := h.store.InvalidateAllCaches(); err != nil { JSONError(w, http.StatusInternalServerError, "Failed to clear cache", err) return } - if err := h.store.ClearSyncToken("todoist"); err != nil { - JSONError(w, http.StatusInternalServerError, "Failed to clear sync token", err) - return - } - _ = h.store.AddSyncLogEntry("cache_clear", "Cache cleared — next load fetches fresh data") syncLog, _ := h.store.GetRecentSyncLog(20) @@ -167,8 +148,6 @@ func (h *Handler) HandleToggleSourceConfig(w http.ResponseWriter, r *http.Reques switch source { case "trello": _ = h.store.InvalidateCache("trello_boards") - case "todoist": - _ = h.store.InvalidateCache("todoist_tasks") } w.Header().Set("Content-Type", "application/json") diff --git a/internal/handlers/tab_state_test.go b/internal/handlers/tab_state_test.go index d066966..9b34033 100644 --- a/internal/handlers/tab_state_test.go +++ b/internal/handlers/tab_state_test.go @@ -33,10 +33,9 @@ func TestHandleDashboard_TabState(t *testing.T) { } h := &Handler{ - store: db, - renderer: mock, - todoistClient: &mockTodoistClient{}, - trelloClient: &mockTrelloClient{}, + store: db, + renderer: mock, + trelloClient: &mockTrelloClient{}, config: &config.Config{ Port: "8080", CacheTTLMinutes: 5, diff --git a/internal/handlers/timeline_logic.go b/internal/handlers/timeline_logic.go index c9510bf..67ba09d 100644 --- a/internal/handlers/timeline_logic.go +++ b/internal/handlers/timeline_logic.go @@ -17,54 +17,7 @@ func BuildTimeline(ctx context.Context, s *store.Store, start, end time.Time) ([ var items []models.TimelineItem now := config.Now() - // 1. Fetch Tasks (dated) - tasks, err := s.GetTasksByDateRange(start, end) - if err != nil { - return nil, err - } - for _, task := range tasks { - if task.DueDate == nil { - continue - } - item := models.TimelineItem{ - ID: task.ID, - Type: models.TimelineItemTypeTask, - Title: task.Content, - Time: *task.DueDate, - Description: task.Description, - URL: task.URL, - OriginalItem: task, - IsCompleted: task.Completed, - Source: "todoist", - } - item.ComputeDaySection(now) - items = append(items, item) - } - - // 1b. Fetch undated Tasks — place in Today section - undatedTasks, err := s.GetUndatedTasks() - if err != nil { - log.Printf("Warning: failed to fetch undated tasks: %v", err) - } else { - for _, task := range undatedTasks { - item := models.TimelineItem{ - ID: task.ID, - Type: models.TimelineItemTypeTask, - Title: task.Content, - Time: now, - Description: task.Description, - URL: task.URL, - OriginalItem: task, - IsCompleted: task.Completed, - Source: "todoist", - IsAllDay: true, - } - item.ComputeDaySection(now) - items = append(items, item) - } - } - - // 2. Fetch Meals - combine multiple items for same date+mealType + // 1. Fetch Meals - combine multiple items for same date+mealType meals, err := s.GetMealsByDateRange(start, end) if err != nil { return nil, err diff --git a/internal/handlers/timeline_logic_test.go b/internal/handlers/timeline_logic_test.go index 03bc291..3678b62 100644 --- a/internal/handlers/timeline_logic_test.go +++ b/internal/handlers/timeline_logic_test.go @@ -46,20 +46,6 @@ func setupTestStore(t *testing.T) *store.Store { } schema := ` - CREATE TABLE IF NOT EXISTS tasks ( - id TEXT PRIMARY KEY, - content TEXT NOT NULL, - description TEXT, - project_id TEXT, - project_name TEXT, - due_date DATETIME, - priority INTEGER DEFAULT 1, - completed BOOLEAN DEFAULT FALSE, - labels TEXT, - url TEXT, - created_at DATETIME, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); CREATE TABLE IF NOT EXISTS meals ( id TEXT PRIMARY KEY, recipe_name TEXT NOT NULL, @@ -118,15 +104,9 @@ func setupTestStore(t *testing.T) *store.Store { func TestBuildTimeline(t *testing.T) { s := setupTestStore(t) - + // Fix a base time: 2023-01-01 08:00:00 baseTime := time.Date(2023, 1, 1, 8, 0, 0, 0, time.UTC) - - // Task: 10:00 - taskDate := baseTime.Add(2 * time.Hour) - _ = s.SaveTasks([]models.Task{ - {ID: "t1", Content: "Task 1", DueDate: &taskDate}, - }) // Meal: Lunch (defaults to 12:00) mealDate := baseTime // Date part matters @@ -138,7 +118,7 @@ func TestBuildTimeline(t *testing.T) { cardDate := baseTime.Add(6 * time.Hour) _ = s.SaveBoards([]models.Board{ { - ID: "b1", + ID: "b1", Name: "Board 1", Cards: []models.Card{ {ID: "c1", Name: "Card 1", DueDate: &cardDate, ListID: "l1"}, @@ -161,27 +141,23 @@ func TestBuildTimeline(t *testing.T) { t.Fatalf("BuildTimeline failed: %v", err) } - if len(items) != 4 { - t.Errorf("Expected 4 items, got %d", len(items)) + if len(items) != 3 { + t.Errorf("Expected 3 items, got %d", len(items)) } // Expected Order: // 1. Event (09:00) - // 2. Task (10:00) - // 3. Meal (12:00) - // 4. Card (14:00) + // 2. Meal (12:00) + // 3. Card (14:00) if items[0].Type != models.TimelineItemTypeEvent { t.Errorf("Expected item 0 to be Event, got %s", items[0].Type) } - if items[1].Type != models.TimelineItemTypeTask { - t.Errorf("Expected item 1 to be Task, got %s", items[1].Type) + if items[1].Type != models.TimelineItemTypeMeal { + t.Errorf("Expected item 1 to be Meal, got %s", items[1].Type) } - if items[2].Type != models.TimelineItemTypeMeal { - t.Errorf("Expected item 2 to be Meal, got %s", items[2].Type) - } - if items[3].Type != models.TimelineItemTypeCard { - t.Errorf("Expected item 3 to be Card, got %s", items[3].Type) + if items[2].Type != models.TimelineItemTypeCard { + t.Errorf("Expected item 2 to be Card, got %s", items[2].Type) } } @@ -273,78 +249,6 @@ func TestCalcCalendarBounds(t *testing.T) { } } -func TestBuildTimeline_IncludesOverdueItems(t *testing.T) { - s := setupTestStore(t) - - // Base: "today" is Jan 2, 2023 - today := time.Date(2023, 1, 2, 0, 0, 0, 0, time.UTC) - yesterday := time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC) // overdue - todayNoon := time.Date(2023, 1, 2, 12, 0, 0, 0, time.UTC) // current - - // Save a task that is overdue (yesterday) and one that is current (today) - _ = s.SaveTasks([]models.Task{ - {ID: "overdue1", Content: "Overdue task", DueDate: &yesterday}, - {ID: "current1", Content: "Current task", DueDate: &todayNoon}, - }) - - // Query range: today through tomorrow - end := today.AddDate(0, 0, 1) - - items, err := BuildTimeline(context.Background(), s, today, end) - if err != nil { - t.Fatalf("BuildTimeline failed: %v", err) - } - - // Should include both the overdue task and the current task - if len(items) < 2 { - t.Errorf("Expected at least 2 items (overdue + current), got %d", len(items)) - for _, item := range items { - t.Logf(" item: %s (type=%s, time=%s)", item.Title, item.Type, item.Time) - } - } - - // Verify overdue task is marked as overdue - foundOverdue := false - for _, item := range items { - if item.ID == "overdue1" { - foundOverdue = true - if !item.IsOverdue { - t.Error("Expected overdue task to be marked IsOverdue=true") - } - if item.DaySection != models.DaySectionToday { - t.Errorf("Expected overdue task in Today section, got %s", item.DaySection) - } - } - } - if !foundOverdue { - t.Error("Overdue task was not included in timeline results") - } -} - -func TestBuildTimeline_ExcludesCompletedOverdue(t *testing.T) { - s := setupTestStore(t) - - yesterday := time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC) - today := time.Date(2023, 1, 2, 0, 0, 0, 0, time.UTC) - end := today.AddDate(0, 0, 1) - - // Save a completed overdue task — should NOT appear - _ = s.SaveTasks([]models.Task{ - {ID: "done1", Content: "Done overdue", DueDate: &yesterday, Completed: true}, - }) - - items, err := BuildTimeline(context.Background(), s, today, end) - if err != nil { - t.Fatalf("BuildTimeline failed: %v", err) - } - - for _, item := range items { - if item.ID == "done1" { - t.Error("Completed overdue task should not appear in timeline") - } - } -} - func TestBuildTimeline_ReadsCalendarEventsFromStore(t *testing.T) { s := setupTestStore(t) @@ -483,36 +387,6 @@ func TestSaveAndGetCalendarEvents(t *testing.T) { } } -func TestBuildTimeline_IncludesUndatedTodoistTasks(t *testing.T) { - s := setupTestStore(t) - - // Save a task with no due date - _ = s.SaveTasks([]models.Task{ - {ID: "undated1", Content: "Take out recycling"}, // DueDate is nil - }) - - start := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC) - end := time.Date(2023, 1, 2, 0, 0, 0, 0, time.UTC) - - items, err := BuildTimeline(context.Background(), s, start, end) - if err != nil { - t.Fatalf("BuildTimeline failed: %v", err) - } - - found := false - for _, item := range items { - if item.ID == "undated1" { - found = true - if item.DaySection != models.DaySectionToday { - t.Errorf("Undated task should be in Today section, got %s", item.DaySection) - } - } - } - if !found { - t.Error("Undated Todoist task should appear in timeline Today section") - } -} - func timePtr(t time.Time) *time.Time { return &t } diff --git a/internal/handlers/widget.go b/internal/handlers/widget.go index ee13dc4..876aaad 100644 --- a/internal/handlers/widget.go +++ b/internal/handlers/widget.go @@ -47,7 +47,7 @@ func TimelineItemToWidgetItem(item models.TimelineItem) models.WidgetItem { // not completable via widget API default: wi.Type = "task" - wi.Completable = item.Source == "todoist" || item.Source == "doot" + wi.Completable = item.Source == "doot" } // Only populate Start/End for items with a real time (non-all-day, non-zero) @@ -147,15 +147,6 @@ func (h *Handler) HandleWidgetComplete(w http.ResponseWriter, r *http.Request) { return } _ = h.store.SaveCompletedTask("doot", req.ID, "", nil) - case "todoist": - if h.todoistClient == nil { - http.Error(w, "todoist not configured", http.StatusServiceUnavailable) - return - } - if err := h.todoistClient.CompleteTask(r.Context(), req.ID); err != nil { - http.Error(w, "failed to complete task", http.StatusInternalServerError) - return - } default: http.Error(w, "source not completable", http.StatusBadRequest) return diff --git a/internal/handlers/widget_test.go b/internal/handlers/widget_test.go index 5b054a0..f56fe07 100644 --- a/internal/handlers/widget_test.go +++ b/internal/handlers/widget_test.go @@ -61,11 +61,11 @@ func TestTimelineItemToWidgetItem_Task(t *testing.T) { item := models.TimelineItem{ ID: "abc", Title: "Write notes", - Source: "todoist", + Source: "doot", Type: models.TimelineItemTypeTask, Time: now, IsAllDay: true, - URL: "https://todoist.com/task/abc", + URL: "https://example.com/task/abc", } wi := TimelineItemToWidgetItem(item) @@ -77,7 +77,7 @@ func TestTimelineItemToWidgetItem_Task(t *testing.T) { t.Errorf("Type: got %q, want %q", wi.Type, "task") } if !wi.Completable { - t.Error("todoist task should be completable") + t.Error("doot task should be completable") } if wi.Start != nil { t.Error("all-day item should have nil Start") @@ -109,7 +109,7 @@ func TestTimelineItemToWidgetItem_Event(t *testing.T) { } } -func TestHandleWidgetComplete_NonTodoist(t *testing.T) { +func TestHandleWidgetComplete_NonCompletable(t *testing.T) { h := &Handler{} body := `{"id":"x","source":"calendar"}` req := httptest.NewRequest("POST", "/api/widget/complete", strings.NewReader(body)) diff --git a/internal/models/atom.go b/internal/models/atom.go index 967b489..9f40de6 100644 --- a/internal/models/atom.go +++ b/internal/models/atom.go @@ -10,7 +10,6 @@ type AtomSource string const ( SourceTrello AtomSource = "trello" - SourceTodoist AtomSource = "todoist" SourceMeal AtomSource = "plantoeat" SourceGTasks AtomSource = "gtasks" SourceClaudomator AtomSource = "claudomator" @@ -74,35 +73,6 @@ func (a *Atom) ComputeUIFields() { a.HasSetTime = dueInTZ.Hour() != 0 || dueInTZ.Minute() != 0 } -// TaskToAtom converts a Todoist Task to an Atom -func TaskToAtom(t Task) Atom { - // Todoist priority: 1 (normal) to 4 (urgent) - // Keep as-is since it already matches our 1-4 scale - priority := t.Priority - if priority < 1 { - priority = 1 - } - if priority > 4 { - priority = 4 - } - - return Atom{ - ID: t.ID, - Title: t.Content, - Description: t.Description, - Source: SourceTodoist, - Type: TypeTask, - URL: t.URL, - DueDate: t.DueDate, - CreatedAt: t.CreatedAt, - Priority: priority, - SourceIcon: "🔴", // Red circle for Todoist - ColorClass: "border-red-500", - IsRecurring: t.IsRecurring, - Raw: t, - } -} - // NativeTaskToAtom converts a native doot Task to an Atom. func NativeTaskToAtom(t Task) Atom { priority := t.Priority diff --git a/internal/models/atom_test.go b/internal/models/atom_test.go index 53bf343..6f86949 100644 --- a/internal/models/atom_test.go +++ b/internal/models/atom_test.go @@ -5,60 +5,6 @@ import ( "time" ) -func TestTaskToAtom(t *testing.T) { - now := time.Now() - task := Task{ - ID: "task-123", - Content: "Test task", - Description: "Task description", - DueDate: &now, - Priority: 3, - URL: "https://todoist.com/task/123", - CreatedAt: now, - IsRecurring: true, - } - - atom := TaskToAtom(task) - - if atom.ID != "task-123" { - t.Errorf("Expected ID 'task-123', got '%s'", atom.ID) - } - if atom.Title != "Test task" { - t.Errorf("Expected title 'Test task', got '%s'", atom.Title) - } - if atom.Source != SourceTodoist { - t.Errorf("Expected source Todoist, got '%s'", atom.Source) - } - if atom.Type != TypeTask { - t.Errorf("Expected type Task, got '%s'", atom.Type) - } - if atom.Priority != 3 { - t.Errorf("Expected priority 3, got %d", atom.Priority) - } - if atom.SourceIcon != "🔴" { - t.Error("Expected red circle icon") - } - if !atom.IsRecurring { - t.Error("Expected IsRecurring to be true") - } -} - -func TestTaskToAtom_PriorityClamping(t *testing.T) { - // Test priority below 1 - lowTask := Task{Priority: 0} - lowAtom := TaskToAtom(lowTask) - if lowAtom.Priority != 1 { - t.Errorf("Priority should be clamped to 1, got %d", lowAtom.Priority) - } - - // Test priority above 4 - highTask := Task{Priority: 10} - highAtom := TaskToAtom(highTask) - if highAtom.Priority != 4 { - t.Errorf("Priority should be clamped to 4, got %d", highAtom.Priority) - } -} - func TestCardToAtom(t *testing.T) { now := time.Now() card := Card{ diff --git a/internal/models/timeline.go b/internal/models/timeline.go index c4196bd..ab486a8 100644 --- a/internal/models/timeline.go +++ b/internal/models/timeline.go @@ -39,7 +39,7 @@ type TimelineItem struct { IsOverdue bool `json:"is_overdue"` IsAllDay bool `json:"is_all_day"` // True if time is midnight (no specific time) DaySection DaySection `json:"day_section"` - Source string `json:"source"` // "todoist", "trello", "plantoeat", "calendar", "gtasks" + Source string `json:"source"` // "trello", "plantoeat", "calendar", "gtasks" // Source-specific metadata ListID string `json:"list_id,omitempty"` // For Google Tasks diff --git a/internal/models/types.go b/internal/models/types.go index 0b025ce..d9dd515 100644 --- a/internal/models/types.go +++ b/internal/models/types.go @@ -6,7 +6,7 @@ import ( "time" ) -// Task represents a task from Todoist +// Task represents a native task type Task struct { ID string `json:"id"` Content string `json:"content"` @@ -118,7 +118,7 @@ type Card struct { URL string `json:"url"` } -// Project represents a Todoist project +// Project represents a project (used for source config grouping) type Project struct { ID string `json:"id"` Name string `json:"name"` @@ -249,7 +249,7 @@ type CompletedTask struct { // SourceConfig represents a configurable item from a data source type SourceConfig struct { ID int64 `json:"id"` - Source string `json:"source"` // trello, todoist, gcal, gtasks + Source string `json:"source"` // trello, gcal, gtasks ItemType string `json:"item_type"` // board, project, calendar, tasklist ItemID string `json:"item_id"` ItemName string `json:"item_name"` diff --git a/internal/models/widget.go b/internal/models/widget.go index 94deec7..8d0bdd6 100644 --- a/internal/models/widget.go +++ b/internal/models/widget.go @@ -3,7 +3,7 @@ package models import "time" // WidgetItem is a single item in the widget API response. -// Source values: "todoist", "trello", "plantoeat", "calendar", "gtasks" +// Source values: "doot", "trello", "plantoeat", "calendar", "gtasks" // Type values: "task", "event" type WidgetItem struct { ID string `json:"id"` @@ -14,7 +14,7 @@ type WidgetItem struct { End *time.Time `json:"end,omitempty"` IsAllDay bool `json:"is_all_day"` URL string `json:"url,omitempty"` - Completable bool `json:"completable"` // true = todoist task (checkbox shown) + Completable bool `json:"completable"` // true = doot task (checkbox shown) } // WidgetResponse is the full /api/widget response body. diff --git a/internal/store/native_tasks.go b/internal/store/native_tasks.go index ca19377..5758407 100644 --- a/internal/store/native_tasks.go +++ b/internal/store/native_tasks.go @@ -2,7 +2,6 @@ package store import ( "encoding/json" - "log" "time" "task-dashboard/internal/models" @@ -96,26 +95,6 @@ func (s *Store) UncompleteNativeTask(id string) error { return err } -// ImportFromTodoist copies all incomplete tasks from the Todoist cache into native_tasks. -// Skips tasks already present (by ID). Returns the count of newly imported tasks. -func (s *Store) ImportFromTodoist() (int, error) { - result, err := s.db.Exec(` - INSERT OR IGNORE INTO native_tasks - (id, content, description, project_name, due_date, priority, completed, labels, created_at, updated_at) - SELECT - id, content, COALESCE(description, ''), COALESCE(project_name, ''), - due_date, priority, 0, COALESCE(labels, '[]'), created_at, CURRENT_TIMESTAMP - FROM tasks - WHERE completed = 0 - `) - if err != nil { - return 0, err - } - n, _ := result.RowsAffected() - log.Printf("ImportFromTodoist: imported %d tasks", n) - return int(n), nil -} - func scanNativeTasks(rows interface{ Next() bool; Scan(...interface{}) error; Err() error }) ([]models.Task, error) { var tasks []models.Task for rows.Next() { diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index f9c9a6b..a6e9fd4 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -2,7 +2,6 @@ package store import ( "database/sql" - "encoding/json" "errors" "fmt" "log" @@ -19,11 +18,10 @@ import ( // Cache key constants const ( - CacheKeyTodoistTasks = "todoist_tasks" - CacheKeyTrelloBoards = "trello_boards" - CacheKeyPlanToEatMeals = "plantoeat_meals" - CacheKeyGoogleCalendar = "google_calendar" - CacheKeyGoogleTasks = "google_tasks" + CacheKeyTrelloBoards = "trello_boards" + CacheKeyPlanToEatMeals = "plantoeat_meals" + CacheKeyGoogleCalendar = "google_calendar" + CacheKeyGoogleTasks = "google_tasks" ) type Store struct { @@ -139,177 +137,6 @@ func (s *Store) runMigrations() error { return nil } -// Tasks operations - -// scanTask scans a single task row from the database -func scanTask(rows *sql.Rows) (models.Task, error) { - var task models.Task - var labelsJSON string - var dueDate sql.NullTime - - err := rows.Scan( - &task.ID, - &task.Content, - &task.Description, - &task.ProjectID, - &task.ProjectName, - &dueDate, - &task.Priority, - &task.Completed, - &labelsJSON, - &task.URL, - &task.CreatedAt, - ) - if err != nil { - return task, err - } - - if dueDate.Valid { - task.DueDate = &dueDate.Time - } - - if err := json.Unmarshal([]byte(labelsJSON), &task.Labels); err != nil { - log.Printf("Warning: failed to unmarshal labels for task %s: %v", task.ID, err) - task.Labels = []string{} - } - return task, nil -} - -// scanTasks scans multiple task rows from the database -func scanTasks(rows *sql.Rows) ([]models.Task, error) { - var tasks []models.Task - for rows.Next() { - task, err := scanTask(rows) - if err != nil { - return nil, err - } - tasks = append(tasks, task) - } - return tasks, rows.Err() -} - -// SaveTasks saves multiple tasks to the database -func (s *Store) SaveTasks(tasks []models.Task) error { - tx, err := s.db.Begin() - if err != nil { - return err - } - defer func() { _ = tx.Rollback() }() - - // Clear existing tasks first to remove stale data - if _, err := tx.Exec(`DELETE FROM tasks`); err != nil { - return err - } - - stmt, err := tx.Prepare(` - INSERT OR REPLACE INTO tasks - (id, content, description, project_id, project_name, due_date, priority, completed, labels, url, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) - `) - if err != nil { - return err - } - defer func() { _ = stmt.Close() }() - - for _, task := range tasks { - labelsJSON, err := json.Marshal(task.Labels) - if err != nil { - log.Printf("Warning: failed to marshal labels for task %s: %v", task.ID, err) - labelsJSON = []byte("[]") - } - _, err = stmt.Exec( - task.ID, - task.Content, - task.Description, - task.ProjectID, - task.ProjectName, - task.DueDate, - task.Priority, - task.Completed, - string(labelsJSON), - task.URL, - task.CreatedAt, - ) - if err != nil { - return err - } - } - - return tx.Commit() -} - -// GetTasks retrieves all tasks from the database -func (s *Store) GetTasks() ([]models.Task, error) { - rows, err := s.db.Query(` - SELECT id, content, description, project_id, project_name, due_date, priority, completed, labels, url, created_at - FROM tasks - ORDER BY completed ASC, CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, due_date ASC, priority DESC - `) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - return scanTasks(rows) -} - -// DeleteTask removes a task from the cache by ID -func (s *Store) DeleteTask(id string) error { - _, err := s.db.Exec(`DELETE FROM tasks WHERE id = ?`, id) - return err -} - -// UpsertTask inserts or updates a single task -func (s *Store) UpsertTask(task models.Task) error { - labelsJSON, err := json.Marshal(task.Labels) - if err != nil { - log.Printf("Warning: failed to marshal labels for task %s: %v", task.ID, err) - labelsJSON = []byte("[]") - } - _, err = s.db.Exec(` - INSERT OR REPLACE INTO tasks - (id, content, description, project_id, project_name, due_date, priority, completed, labels, url, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) - `, - task.ID, - task.Content, - task.Description, - task.ProjectID, - task.ProjectName, - task.DueDate, - task.Priority, - task.Completed, - string(labelsJSON), - task.URL, - task.CreatedAt, - ) - return err -} - -// DeleteTasksByIDs removes multiple tasks by ID -func (s *Store) DeleteTasksByIDs(ids []string) error { - if len(ids) == 0 { - return nil - } - tx, err := s.db.Begin() - if err != nil { - return err - } - defer func() { _ = tx.Rollback() }() - - stmt, err := tx.Prepare(`DELETE FROM tasks WHERE id = ?`) - if err != nil { - return err - } - defer func() { _ = stmt.Close() }() - - for _, id := range ids { - if _, err := stmt.Exec(id); err != nil { - return err - } - } - return tx.Commit() -} - // Meals operations // SaveMeals saves multiple meals to the database @@ -707,38 +534,6 @@ func (s *Store) GetShoppingItemChecks(source string) (map[string]bool, error) { return checks, rows.Err() } -// GetTasksByDateRange retrieves tasks due within a specific date range, -// including overdue tasks (due before start) so they appear in the timeline. -func (s *Store) GetTasksByDateRange(start, end time.Time) ([]models.Task, error) { - rows, err := s.db.Query(` - SELECT id, content, description, project_id, project_name, due_date, priority, completed, labels, url, created_at - FROM tasks - WHERE due_date IS NOT NULL AND due_date <= ? - AND completed = FALSE - ORDER BY due_date ASC, priority DESC - `, end) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - return scanTasks(rows) -} - -// GetUndatedTasks retrieves incomplete tasks with no due date. -func (s *Store) GetUndatedTasks() ([]models.Task, error) { - rows, err := s.db.Query(` - SELECT id, content, description, project_id, project_name, due_date, priority, completed, labels, url, created_at - FROM tasks - WHERE due_date IS NULL AND completed = FALSE - ORDER BY priority DESC, created_at ASC - `) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - return scanTasks(rows) -} - // GetMealsByDateRange retrieves meals within a specific date range func (s *Store) GetMealsByDateRange(start, end time.Time) ([]models.Meal, error) { return s.GetMeals(start, end) @@ -1508,35 +1303,11 @@ func (s *Store) SyncSourceConfigs(source, itemType string, items []models.Source // InvalidateAllCaches removes cache metadata for all known cache keys func (s *Store) InvalidateAllCaches() error { - _, err := s.db.Exec(`DELETE FROM cache_metadata WHERE key IN (?, ?, ?, ?, ?)`, - CacheKeyTodoistTasks, CacheKeyTrelloBoards, CacheKeyPlanToEatMeals, CacheKeyGoogleCalendar, CacheKeyGoogleTasks) + _, err := s.db.Exec(`DELETE FROM cache_metadata WHERE key IN (?, ?, ?, ?)`, + CacheKeyTrelloBoards, CacheKeyPlanToEatMeals, CacheKeyGoogleCalendar, CacheKeyGoogleTasks) return err } -// GetProjectsFromTasks returns distinct projects from the tasks table -func (s *Store) GetProjectsFromTasks() ([]models.Project, error) { - rows, err := s.db.Query(` - SELECT DISTINCT project_id, project_name - FROM tasks - WHERE project_id != '' - ORDER BY project_name - `) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - - var projects []models.Project - for rows.Next() { - var p models.Project - if err := rows.Scan(&p.ID, &p.Name); err != nil { - return nil, err - } - projects = append(projects, p) - } - return projects, rows.Err() -} - // Feature toggles // GetFeatureToggles returns all feature toggles diff --git a/internal/store/sqlite_test.go b/internal/store/sqlite_test.go index 0514eeb..2467f96 100644 --- a/internal/store/sqlite_test.go +++ b/internal/store/sqlite_test.go @@ -78,45 +78,6 @@ func TestRunMigrations_IdempotentOnRestart(t *testing.T) { store2.Close() } -// setupTestStoreWithTasks creates a test store with tasks table -func setupTestStoreWithTasks(t *testing.T) *Store { - t.Helper() - - tempDir := t.TempDir() - dbPath := filepath.Join(tempDir, "test.db") - - db, err := sql.Open("sqlite3", dbPath) - if err != nil { - t.Fatalf("Failed to open test database: %v", err) - } - - db.SetMaxOpenConns(1) - - store := &Store{db: db} - - schema := ` - CREATE TABLE IF NOT EXISTS tasks ( - id TEXT PRIMARY KEY, - content TEXT NOT NULL, - description TEXT, - project_id TEXT, - project_name TEXT, - due_date DATETIME, - priority INTEGER DEFAULT 1, - completed BOOLEAN DEFAULT FALSE, - labels TEXT, - url TEXT, - created_at DATETIME, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - ` - if _, err := db.Exec(schema); err != nil { - t.Fatalf("Failed to create schema: %v", err) - } - - return store -} - // setupTestStoreWithCards creates a test store with boards and cards tables func setupTestStoreWithCards(t *testing.T) *Store { t.Helper() @@ -190,106 +151,6 @@ func setupTestStoreWithMeals(t *testing.T) *Store { return store } -// TestDeleteTask verifies that DeleteTask removes a task from the cache -func TestDeleteTask(t *testing.T) { - store := setupTestStoreWithTasks(t) - defer func() { _ = store.Close() }() - - // Save some tasks - tasks := []models.Task{ - { - ID: "task1", - Content: "Task 1", - Description: "Description 1", - Priority: 1, - Completed: false, - Labels: []string{}, - CreatedAt: time.Now(), - }, - { - ID: "task2", - Content: "Task 2", - Description: "Description 2", - Priority: 2, - Completed: false, - Labels: []string{}, - CreatedAt: time.Now(), - }, - { - ID: "task3", - Content: "Task 3", - Description: "Description 3", - Priority: 3, - Completed: false, - Labels: []string{}, - CreatedAt: time.Now(), - }, - } - - if err := store.SaveTasks(tasks); err != nil { - t.Fatalf("Failed to save tasks: %v", err) - } - - // Verify all 3 tasks exist - result, err := store.GetTasks() - if err != nil { - t.Fatalf("Failed to get tasks: %v", err) - } - if len(result) != 3 { - t.Fatalf("Expected 3 tasks, got %d", len(result)) - } - - // Delete task2 - if err := store.DeleteTask("task2"); err != nil { - t.Fatalf("DeleteTask failed: %v", err) - } - - // Verify only 2 tasks remain - result, err = store.GetTasks() - if err != nil { - t.Fatalf("Failed to get tasks after delete: %v", err) - } - if len(result) != 2 { - t.Errorf("Expected 2 tasks after delete, got %d", len(result)) - } - - // Verify task2 is gone - for _, task := range result { - if task.ID == "task2" { - t.Errorf("task2 should have been deleted but was found") - } - } - - // Verify task1 and task3 still exist - foundTask1, foundTask3 := false, false - for _, task := range result { - if task.ID == "task1" { - foundTask1 = true - } - if task.ID == "task3" { - foundTask3 = true - } - } - if !foundTask1 { - t.Error("task1 should still exist") - } - if !foundTask3 { - t.Error("task3 should still exist") - } -} - -// TestDeleteTask_NonExistent verifies that deleting a non-existent task doesn't error -func TestDeleteTask_NonExistent(t *testing.T) { - store := setupTestStoreWithTasks(t) - defer func() { _ = store.Close() }() - - // Delete a task that doesn't exist - should not error - err := store.DeleteTask("nonexistent") - if err != nil { - t.Errorf("DeleteTask on non-existent task should not error, got: %v", err) - } -} - // TestDeleteCard verifies that DeleteCard removes a card from the cache func TestDeleteCard(t *testing.T) { store := setupTestStoreWithCards(t) @@ -532,99 +393,6 @@ func TestSaveAndGetBoards_ManyBoards(t *testing.T) { } } -func TestGetTasksByDateRange(t *testing.T) { - store := setupTestStoreWithTasks(t) - defer func() { _ = store.Close() }() - - now := time.Now() - tomorrow := now.Add(24 * time.Hour) - nextWeek := now.Add(7 * 24 * time.Hour) - - tasks := []models.Task{ - {ID: "1", Content: "Task 1", DueDate: &now}, - {ID: "2", Content: "Task 2", DueDate: &tomorrow}, - {ID: "3", Content: "Task 3", DueDate: &nextWeek}, - } - - if err := store.SaveTasks(tasks); err != nil { - t.Fatalf("Failed to save tasks: %v", err) - } - - // Test range covering today and tomorrow — should include all tasks <= end - start := now.Add(-1 * time.Hour) - end := tomorrow.Add(1 * time.Hour) - - results, err := store.GetTasksByDateRange(start, end) - if err != nil { - t.Fatalf("GetTasksByDateRange failed: %v", err) - } - - if len(results) != 2 { - t.Errorf("Expected 2 tasks (today + tomorrow, not next week), got %d", len(results)) - } -} - -func TestGetTasksByDateRange_IncludesOverdue(t *testing.T) { - store := setupTestStoreWithTasks(t) - defer func() { _ = store.Close() }() - - now := time.Now() - yesterday := now.Add(-24 * time.Hour) - tomorrow := now.Add(24 * time.Hour) - - tasks := []models.Task{ - {ID: "overdue", Content: "Overdue", DueDate: &yesterday}, - {ID: "current", Content: "Current", DueDate: &now}, - } - - if err := store.SaveTasks(tasks); err != nil { - t.Fatalf("Failed to save tasks: %v", err) - } - - // Query from "today" onward — overdue tasks (before start) should also be included - results, err := store.GetTasksByDateRange(now, tomorrow) - if err != nil { - t.Fatalf("GetTasksByDateRange failed: %v", err) - } - - if len(results) != 2 { - t.Errorf("Expected 2 tasks (overdue + current), got %d", len(results)) - for _, r := range results { - t.Logf(" task: %s due=%v", r.Content, r.DueDate) - } - } -} - -func TestGetTasksByDateRange_ExcludesCompleted(t *testing.T) { - store := setupTestStoreWithTasks(t) - defer func() { _ = store.Close() }() - - now := time.Now() - yesterday := now.Add(-24 * time.Hour) - tomorrow := now.Add(24 * time.Hour) - - tasks := []models.Task{ - {ID: "done", Content: "Completed overdue", DueDate: &yesterday, Completed: true}, - {ID: "active", Content: "Active", DueDate: &now}, - } - - if err := store.SaveTasks(tasks); err != nil { - t.Fatalf("Failed to save tasks: %v", err) - } - - results, err := store.GetTasksByDateRange(now, tomorrow) - if err != nil { - t.Fatalf("GetTasksByDateRange failed: %v", err) - } - - if len(results) != 1 { - t.Errorf("Expected 1 task (only active), got %d", len(results)) - } - if len(results) > 0 && results[0].ID != "active" { - t.Errorf("Expected active task, got %s", results[0].ID) - } -} - func TestGetMealsByDateRange(t *testing.T) { store := setupTestStoreWithMeals(t) defer func() { _ = store.Close() }() @@ -982,7 +750,7 @@ func TestCompletedTasks_SaveAndGet(t *testing.T) { now := time.Now() // Save completed task with due date - if err := store.SaveCompletedTask("todoist", "task-123", "Buy groceries", &now); err != nil { + if err := store.SaveCompletedTask("doot", "task-123", "Buy groceries", &now); err != nil { t.Fatalf("Failed to save completed task: %v", err) } @@ -1001,17 +769,17 @@ func TestCompletedTasks_SaveAndGet(t *testing.T) { } // Verify task data - var todoistTask models.CompletedTask + var dootTask models.CompletedTask for _, task := range tasks { - if task.Source == "todoist" { - todoistTask = task + if task.Source == "doot" { + dootTask = task break } } - if todoistTask.Title != "Buy groceries" { - t.Errorf("Expected title 'Buy groceries', got '%s'", todoistTask.Title) + if dootTask.Title != "Buy groceries" { + t.Errorf("Expected title 'Buy groceries', got '%s'", dootTask.Title) } - if todoistTask.DueDate == nil { + if dootTask.DueDate == nil { t.Error("Expected due date to be set") } } @@ -1022,7 +790,7 @@ func TestCompletedTasks_Limit(t *testing.T) { // Save multiple tasks for i := 0; i < 10; i++ { - _ = store.SaveCompletedTask("todoist", "task-"+string(rune('0'+i)), "Task "+string(rune('0'+i)), nil) + _ = store.SaveCompletedTask("doot", "task-"+string(rune('0'+i)), "Task "+string(rune('0'+i)), nil) } // Get with limit @@ -1043,7 +811,7 @@ func TestGetCompletedTasks_CompletedAtIsParsed(t *testing.T) { knownTimestamp := "2026-03-15 14:30:00" _, err := s.db.Exec( `INSERT INTO completed_tasks (source, source_id, title, completed_at) VALUES (?, ?, ?, ?)`, - "todoist", "task-ts-test", "Timestamp Test Task", knownTimestamp, + "doot", "task-ts-test", "Timestamp Test Task", knownTimestamp, ) if err != nil { t.Fatalf("Failed to insert task: %v", err) @@ -1329,7 +1097,7 @@ func TestSyncTokens_SetGetClear(t *testing.T) { defer func() { _ = store.Close() }() // Get non-existent token - token, err := store.GetSyncToken("todoist") + token, err := store.GetSyncToken("trello") if err != nil { t.Fatalf("Failed to get token: %v", err) } @@ -1338,12 +1106,12 @@ func TestSyncTokens_SetGetClear(t *testing.T) { } // Set token - if err := store.SetSyncToken("todoist", "sync-token-123"); err != nil { + if err := store.SetSyncToken("trello", "sync-token-123"); err != nil { t.Fatalf("Failed to set token: %v", err) } // Get token - token, err = store.GetSyncToken("todoist") + token, err = store.GetSyncToken("trello") if err != nil { t.Fatalf("Failed to get token after set: %v", err) } @@ -1352,11 +1120,11 @@ func TestSyncTokens_SetGetClear(t *testing.T) { } // Clear token - if err := store.ClearSyncToken("todoist"); err != nil { + if err := store.ClearSyncToken("trello"); err != nil { t.Fatalf("Failed to clear token: %v", err) } - token, _ = store.GetSyncToken("todoist") + token, _ = store.GetSyncToken("trello") if token != "" { t.Errorf("Expected empty token after clear, got '%s'", token) } -- cgit v1.2.3