summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/api/interfaces.go11
-rw-r--r--internal/api/research_test.go241
-rw-r--r--internal/api/todoist.go256
-rw-r--r--internal/api/todoist_test.go399
-rw-r--r--internal/config/config.go3
-rw-r--r--internal/config/config_test.go27
-rw-r--r--internal/handlers/agent.go38
-rw-r--r--internal/handlers/agent_test.go26
-rw-r--r--internal/handlers/atoms.go14
-rw-r--r--internal/handlers/handlers.go252
-rw-r--r--internal/handlers/handlers_test.go802
-rw-r--r--internal/handlers/settings.go25
-rw-r--r--internal/handlers/tab_state_test.go7
-rw-r--r--internal/handlers/timeline_logic.go49
-rw-r--r--internal/handlers/timeline_logic_test.go146
-rw-r--r--internal/handlers/widget.go11
-rw-r--r--internal/handlers/widget_test.go8
-rw-r--r--internal/models/atom.go30
-rw-r--r--internal/models/atom_test.go54
-rw-r--r--internal/models/timeline.go2
-rw-r--r--internal/models/types.go6
-rw-r--r--internal/models/widget.go4
-rw-r--r--internal/store/native_tasks.go21
-rw-r--r--internal/store/sqlite.go241
-rw-r--r--internal/store/sqlite_test.go260
25 files changed, 125 insertions, 2808 deletions
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
@@ -15,33 +15,22 @@ 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)
}