diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-01-23 21:37:18 -1000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-01-23 21:37:18 -1000 |
| commit | 465093343ddd398ce5f6377fc9c472d8251c618b (patch) | |
| tree | d333a2f1c8879f7b114817e929c95e9fcf5f4c3b /internal | |
| parent | e23c85577cbb0eac8b847dd989072698ff4e7a30 (diff) | |
Refactor: reduce code duplication with shared abstractions
- Add BaseClient HTTP abstraction (internal/api/http.go) to eliminate
duplicated HTTP boilerplate across Todoist, Trello, and PlanToEat clients
- Add response helpers (internal/handlers/response.go) for JSON/HTML responses
- Add generic cache wrapper (internal/handlers/cache.go) using Go generics
- Consolidate HandleCompleteAtom/HandleUncompleteAtom into handleAtomToggle
- Merge TabsHandler into Handler, delete tabs.go
- Extract sortTasksByUrgency and filterAndSortTrelloTasks helpers
- Update tests to work with new BaseClient structure
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/api/http.go | 143 | ||||
| -rw-r--r-- | internal/api/plantoeat.go | 67 | ||||
| -rw-r--r-- | internal/api/todoist.go | 264 | ||||
| -rw-r--r-- | internal/api/todoist_test.go | 38 | ||||
| -rw-r--r-- | internal/api/trello.go | 211 | ||||
| -rw-r--r-- | internal/api/trello_test.go | 35 | ||||
| -rw-r--r-- | internal/handlers/cache.go | 52 | ||||
| -rw-r--r-- | internal/handlers/handlers.go | 1029 | ||||
| -rw-r--r-- | internal/handlers/heuristic_test.go | 152 | ||||
| -rw-r--r-- | internal/handlers/response.go | 36 | ||||
| -rw-r--r-- | internal/handlers/tabs.go | 384 | ||||
| -rw-r--r-- | internal/store/sqlite.go | 97 | ||||
| -rw-r--r-- | internal/store/sqlite_test.go | 139 |
13 files changed, 1212 insertions, 1435 deletions
diff --git a/internal/api/http.go b/internal/api/http.go new file mode 100644 index 0000000..8854625 --- /dev/null +++ b/internal/api/http.go @@ -0,0 +1,143 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// BaseClient provides common HTTP functionality for API clients +type BaseClient struct { + HTTPClient *http.Client + BaseURL string +} + +// NewBaseClient creates a new BaseClient with default settings +func NewBaseClient(baseURL string) BaseClient { + return BaseClient{ + HTTPClient: &http.Client{Timeout: 15 * time.Second}, + BaseURL: baseURL, + } +} + +// Get performs a GET request and decodes the JSON response +func (c *BaseClient) Get(ctx context.Context, path string, headers map[string]string, result interface{}) error { + req, err := http.NewRequestWithContext(ctx, "GET", c.BaseURL+path, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + for k, v := range headers { + req.Header.Set(k, v) + } + + return c.doJSON(req, result) +} + +// Post performs a POST request with JSON body and decodes the response +func (c *BaseClient) Post(ctx context.Context, path string, headers map[string]string, body interface{}, result interface{}) error { + var bodyReader io.Reader + if body != nil { + jsonData, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("failed to marshal request body: %w", err) + } + bodyReader = bytes.NewBuffer(jsonData) + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL+path, bodyReader) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + for k, v := range headers { + req.Header.Set(k, v) + } + + return c.doJSON(req, result) +} + +// PostForm performs a POST request with form-encoded body +func (c *BaseClient) PostForm(ctx context.Context, path string, headers map[string]string, formData string, result interface{}) error { + req, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL+path, bytes.NewBufferString(formData)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for k, v := range headers { + req.Header.Set(k, v) + } + + return c.doJSON(req, result) +} + +// PostEmpty performs a POST request with no body and expects no response body +func (c *BaseClient) PostEmpty(ctx context.Context, path string, headers map[string]string) error { + req, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL+path, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + for k, v := range headers { + req.Header.Set(k, v) + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + } + + return nil +} + +// Put performs a PUT request with form-encoded body +func (c *BaseClient) Put(ctx context.Context, path string, headers map[string]string, formData string, result interface{}) error { + req, err := http.NewRequestWithContext(ctx, "PUT", c.BaseURL+path, bytes.NewBufferString(formData)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for k, v := range headers { + req.Header.Set(k, v) + } + + return c.doJSON(req, result) +} + +// doJSON executes the request and decodes JSON response +func (c *BaseClient) doJSON(req *http.Request, result interface{}) error { + resp, err := c.HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + } + + if result != nil { + if err := json.NewDecoder(resp.Body).Decode(result); err != nil { + return fmt.Errorf("failed to decode response: %w", err) + } + } + + return nil +} diff --git a/internal/api/plantoeat.go b/internal/api/plantoeat.go index 1dae246..eb29c63 100644 --- a/internal/api/plantoeat.go +++ b/internal/api/plantoeat.go @@ -2,35 +2,32 @@ package api import ( "context" - "encoding/json" "fmt" - "io" - "net/http" "time" "task-dashboard/internal/models" ) -const ( - planToEatBaseURL = "https://www.plantoeat.com/api/v2" -) +const planToEatBaseURL = "https://www.plantoeat.com/api/v2" // PlanToEatClient handles interactions with the PlanToEat API type PlanToEatClient struct { - apiKey string - httpClient *http.Client + BaseClient + apiKey string } // NewPlanToEatClient creates a new PlanToEat API client func NewPlanToEatClient(apiKey string) *PlanToEatClient { return &PlanToEatClient{ - apiKey: apiKey, - httpClient: &http.Client{ - Timeout: 15 * time.Second, - }, + BaseClient: NewBaseClient(planToEatBaseURL), + apiKey: apiKey, } } +func (c *PlanToEatClient) authHeaders() map[string]string { + return map[string]string{"Authorization": "Bearer " + c.apiKey} +} + // planToEatPlannerItem represents a planner item from the API type planToEatPlannerItem struct { ID int `json:"id"` @@ -51,59 +48,35 @@ type planToEatResponse struct { // GetUpcomingMeals fetches meals for the next N days func (c *PlanToEatClient) GetUpcomingMeals(ctx context.Context, days int) ([]models.Meal, error) { if days <= 0 { - days = 7 // Default to 7 days + days = 7 } startDate := time.Now() endDate := startDate.AddDate(0, 0, days) - req, err := http.NewRequestWithContext(ctx, "GET", planToEatBaseURL+"/planner_items", nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - // Add query parameters - q := req.URL.Query() - q.Add("start_date", startDate.Format("2006-01-02")) - q.Add("end_date", endDate.Format("2006-01-02")) - req.URL.RawQuery = q.Encode() - - // Add API key (check docs for correct header name) - req.Header.Set("Authorization", "Bearer "+c.apiKey) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to fetch meals: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("plantoeat API error (status %d): %s", resp.StatusCode, string(body)) - } + path := fmt.Sprintf("/planner_items?start_date=%s&end_date=%s", + startDate.Format("2006-01-02"), + endDate.Format("2006-01-02")) var apiResponse planToEatResponse - if err := json.NewDecoder(resp.Body).Decode(&apiResponse); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + if err := c.Get(ctx, path, c.authHeaders(), &apiResponse); err != nil { + return nil, fmt.Errorf("failed to fetch meals: %w", err) } - // Convert to our model meals := make([]models.Meal, 0, len(apiResponse.Items)) for _, item := range apiResponse.Items { mealDate, err := time.Parse("2006-01-02", item.Date) if err != nil { - continue // Skip invalid dates + continue } - meal := models.Meal{ + meals = append(meals, models.Meal{ ID: fmt.Sprintf("%d", item.ID), RecipeName: item.Recipe.Title, Date: mealDate, MealType: normalizeMealType(item.MealType), RecipeURL: item.Recipe.URL, - } - - meals = append(meals, meal) + }) } return meals, nil @@ -121,18 +94,16 @@ func normalizeMealType(mealType string) string { case "snack", "Snack": return "snack" default: - return "dinner" // Default to dinner + return "dinner" } } // GetRecipes fetches recipes (for Phase 2) func (c *PlanToEatClient) GetRecipes(ctx context.Context) error { - // This will be implemented in Phase 2 return fmt.Errorf("not implemented yet") } // AddMealToPlanner adds a meal to the planner (for Phase 2) func (c *PlanToEatClient) AddMealToPlanner(ctx context.Context, recipeID string, date time.Time, mealType string) error { - // This will be implemented in Phase 2 return fmt.Errorf("not implemented yet") } diff --git a/internal/api/todoist.go b/internal/api/todoist.go index b3d4579..6c998cf 100644 --- a/internal/api/todoist.go +++ b/internal/api/todoist.go @@ -1,12 +1,8 @@ package api import ( - "bytes" "context" - "encoding/json" "fmt" - "io" - "net/http" "time" "task-dashboard/internal/models" @@ -19,22 +15,24 @@ const ( // TodoistClient handles interactions with the Todoist API type TodoistClient struct { + BaseClient + syncClient BaseClient apiKey string - baseURL string - httpClient *http.Client } // NewTodoistClient creates a new Todoist API client func NewTodoistClient(apiKey string) *TodoistClient { return &TodoistClient{ - apiKey: apiKey, - baseURL: todoistBaseURL, - httpClient: &http.Client{ - Timeout: 15 * time.Second, - }, + BaseClient: NewBaseClient(todoistBaseURL), + syncClient: NewBaseClient(todoistSyncBaseURL), + 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"` @@ -93,34 +91,15 @@ type SyncProjectResponse struct { // GetTasks fetches all active tasks from Todoist func (c *TodoistClient) GetTasks(ctx context.Context) ([]models.Task, error) { - req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/tasks", nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Authorization", "Bearer "+c.apiKey) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to fetch tasks: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("todoist API error (status %d): %s", resp.StatusCode, string(body)) - } - var apiTasks []todoistTaskResponse - if err := json.NewDecoder(resp.Body).Decode(&apiTasks); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + if err := c.Get(ctx, "/tasks", c.authHeaders(), &apiTasks); err != nil { + return nil, fmt.Errorf("failed to fetch tasks: %w", err) } // Fetch projects to get project names projects, err := c.GetProjects(ctx) projectMap := make(map[string]string) if err == nil { - // Build map of project ID to name for _, proj := range projects { projectMap[proj.ID] = proj.Name } @@ -141,24 +120,11 @@ func (c *TodoistClient) GetTasks(ctx context.Context) ([]models.Task, error) { URL: apiTask.URL, } - // Parse created_at if createdAt, err := time.Parse(time.RFC3339, apiTask.CreatedAt); err == nil { task.CreatedAt = createdAt } - // Parse due date - if apiTask.Due != nil { - var dueDate time.Time - if apiTask.Due.Datetime != "" { - dueDate, err = time.Parse(time.RFC3339, apiTask.Due.Datetime) - } else if apiTask.Due.Date != "" { - dueDate, err = time.Parse("2006-01-02", apiTask.Due.Date) - } - if err == nil { - task.DueDate = &dueDate - } - } - + task.DueDate = parseDueDate(apiTask.Due) tasks = append(tasks, task) } @@ -167,30 +133,11 @@ func (c *TodoistClient) GetTasks(ctx context.Context) ([]models.Task, error) { // GetProjects fetches all projects func (c *TodoistClient) GetProjects(ctx context.Context) ([]models.Project, error) { - req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/projects", nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Authorization", "Bearer "+c.apiKey) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to fetch projects: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("todoist API error (status %d): %s", resp.StatusCode, string(body)) - } - var apiProjects []todoistProjectResponse - if err := json.NewDecoder(resp.Body).Decode(&apiProjects); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + if err := c.Get(ctx, "/projects", c.authHeaders(), &apiProjects); err != nil { + return nil, fmt.Errorf("failed to fetch projects: %w", err) } - // Convert to model projects := make([]models.Project, 0, len(apiProjects)) for _, apiProj := range apiProjects { projects = append(projects, models.Project{ @@ -203,46 +150,19 @@ func (c *TodoistClient) GetProjects(ctx context.Context) ([]models.Project, erro } // Sync performs an incremental sync using the Sync API v9 -// If syncToken is empty or "*", a full sync is performed -// Returns the new sync token and the sync response func (c *TodoistClient) Sync(ctx context.Context, syncToken string) (*TodoistSyncResponse, error) { if syncToken == "" { syncToken = "*" // Full sync } - // Prepare sync request payload := map[string]interface{}{ "sync_token": syncToken, "resource_types": []string{"items", "projects"}, } - jsonData, err := json.Marshal(payload) - if err != nil { - return nil, fmt.Errorf("failed to marshal sync request: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, "POST", todoistSyncBaseURL+"/sync", bytes.NewBuffer(jsonData)) - if err != nil { - return nil, fmt.Errorf("failed to create sync request: %w", err) - } - - req.Header.Set("Authorization", "Bearer "+c.apiKey) - req.Header.Set("Content-Type", "application/json") - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to perform sync: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("todoist sync API error (status %d): %s", resp.StatusCode, string(body)) - } - var syncResp TodoistSyncResponse - if err := json.NewDecoder(resp.Body).Decode(&syncResp); err != nil { - return nil, fmt.Errorf("failed to decode sync response: %w", err) + if err := c.syncClient.Post(ctx, "/sync", c.authHeaders(), payload, &syncResp); err != nil { + return nil, fmt.Errorf("failed to perform sync: %w", err) } return &syncResp, nil @@ -252,7 +172,6 @@ func (c *TodoistClient) Sync(ctx context.Context, syncToken string) (*TodoistSyn func ConvertSyncItemsToTasks(items []SyncItemResponse, projectMap map[string]string) []models.Task { tasks := make([]models.Task, 0, len(items)) for _, item := range items { - // Skip completed or deleted items if item.IsCompleted || item.IsDeleted { continue } @@ -269,27 +188,13 @@ func ConvertSyncItemsToTasks(items []SyncItemResponse, projectMap map[string]str URL: fmt.Sprintf("https://todoist.com/app/task/%s", item.ID), } - // Parse added_at if item.AddedAt != "" { if createdAt, err := time.Parse(time.RFC3339, item.AddedAt); err == nil { task.CreatedAt = createdAt } } - // Parse due date - if item.Due != nil { - var dueDate time.Time - var err error - if item.Due.Datetime != "" { - dueDate, err = time.Parse(time.RFC3339, item.Due.Datetime) - } else if item.Due.Date != "" { - dueDate, err = time.Parse("2006-01-02", item.Due.Date) - } - if err == nil { - task.DueDate = &dueDate - } - } - + task.DueDate = parseDueDate(item.Due) tasks = append(tasks, task) } return tasks @@ -308,55 +213,22 @@ func BuildProjectMapFromSync(projects []SyncProjectResponse) map[string]string { // 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) { - // Prepare request payload - payload := map[string]interface{}{ - "content": content, - } - + payload := map[string]interface{}{"content": content} if projectID != "" { payload["project_id"] = projectID } - if dueDate != nil { payload["due_date"] = dueDate.Format("2006-01-02") } - if priority > 0 { payload["priority"] = priority } - jsonData, err := json.Marshal(payload) - if err != nil { - return nil, fmt.Errorf("failed to marshal request: %w", err) - } - - // Create POST request - req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/tasks", bytes.NewBuffer(jsonData)) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Authorization", "Bearer "+c.apiKey) - req.Header.Set("Content-Type", "application/json") - - // Execute request - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to create task: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("todoist API error (status %d): %s", resp.StatusCode, string(body)) - } - - // Decode response var apiTask todoistTaskResponse - if err := json.NewDecoder(resp.Body).Decode(&apiTask); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + if err := c.Post(ctx, "/tasks", c.authHeaders(), payload, &apiTask); err != nil { + return nil, fmt.Errorf("failed to create task: %w", err) } - // Convert to our model task := &models.Task{ ID: apiTask.ID, Content: apiTask.Content, @@ -368,100 +240,58 @@ func (c *TodoistClient) CreateTask(ctx context.Context, content, projectID strin URL: apiTask.URL, } - // Parse created_at if createdAt, err := time.Parse(time.RFC3339, apiTask.CreatedAt); err == nil { task.CreatedAt = createdAt } - // Parse due date - if apiTask.Due != nil { - var taskDueDate time.Time - if apiTask.Due.Datetime != "" { - taskDueDate, err = time.Parse(time.RFC3339, apiTask.Due.Datetime) - } else if apiTask.Due.Date != "" { - taskDueDate, err = time.Parse("2006-01-02", apiTask.Due.Date) - } - if err == nil { - task.DueDate = &taskDueDate - } - } - + 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 { - jsonData, err := json.Marshal(updates) - if err != nil { - return fmt.Errorf("failed to marshal updates: %w", err) - } - - url := fmt.Sprintf("%s/tasks/%s", c.baseURL, taskID) - req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Authorization", "Bearer "+c.apiKey) - req.Header.Set("Content-Type", "application/json") - - resp, err := c.httpClient.Do(req) - if err != nil { + 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) } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("todoist API error (status %d): %s", resp.StatusCode, string(body)) - } - return nil } // CompleteTask marks a task as complete in Todoist func (c *TodoistClient) CompleteTask(ctx context.Context, taskID string) error { - // Create POST request to close endpoint - url := fmt.Sprintf("%s/tasks/%s/close", c.baseURL, taskID) - req, err := http.NewRequestWithContext(ctx, "POST", url, nil) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Authorization", "Bearer "+c.apiKey) - - // Execute request - resp, err := c.httpClient.Do(req) - if err != nil { + 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) } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("todoist API error (status %d): %s", resp.StatusCode, string(body)) - } - return nil } // ReopenTask marks a completed task as active in Todoist func (c *TodoistClient) ReopenTask(ctx context.Context, taskID string) error { - url := fmt.Sprintf("%s/tasks/%s/reopen", c.baseURL, taskID) - req, err := http.NewRequestWithContext(ctx, "POST", url, nil) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Authorization", "Bearer "+c.apiKey) - - resp, err := c.httpClient.Do(req) - if err != nil { + 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) } - defer resp.Body.Close() + return nil +} - if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("todoist API error (status %d): %s", resp.StatusCode, string(body)) +// parseDueDate parses due date from API response +func parseDueDate(due *struct { + Date string `json:"date"` + Datetime string `json:"datetime"` +}) *time.Time { + if due == nil { + return nil + } + var dueDate time.Time + var err error + if due.Datetime != "" { + dueDate, err = time.Parse(time.RFC3339, due.Datetime) + } else if due.Date != "" { + dueDate, err = time.Parse("2006-01-02", due.Date) } - - return nil + if err != nil { + return nil + } + return &dueDate } diff --git a/internal/api/todoist_test.go b/internal/api/todoist_test.go index 56b1484..88f94f8 100644 --- a/internal/api/todoist_test.go +++ b/internal/api/todoist_test.go @@ -10,6 +10,14 @@ import ( "time" ) +// newTestTodoistClient creates a TodoistClient for testing with custom base URL +func newTestTodoistClient(baseURL, apiKey string) *TodoistClient { + client := NewTodoistClient(apiKey) + client.BaseClient.BaseURL = baseURL + client.syncClient.BaseURL = baseURL + return client +} + func TestTodoistClient_CreateTask(t *testing.T) { // Mock server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -59,11 +67,7 @@ func TestTodoistClient_CreateTask(t *testing.T) { defer server.Close() // Create client with mock server URL - client := &TodoistClient{ - apiKey: "test-key", - baseURL: server.URL, - httpClient: &http.Client{}, - } + client := newTestTodoistClient(server.URL, "test-key") // Test CreateTask ctx := context.Background() @@ -122,11 +126,7 @@ func TestTodoistClient_CreateTask_WithDueDate(t *testing.T) { defer server.Close() // Create client - client := &TodoistClient{ - apiKey: "test-key", - baseURL: server.URL, - httpClient: &http.Client{}, - } + client := newTestTodoistClient(server.URL, "test-key") // Test CreateTask with due date ctx := context.Background() @@ -170,11 +170,7 @@ func TestTodoistClient_CompleteTask(t *testing.T) { defer server.Close() // Create client - client := &TodoistClient{ - apiKey: "test-key", - baseURL: server.URL, - httpClient: &http.Client{}, - } + client := newTestTodoistClient(server.URL, "test-key") // Test CompleteTask ctx := context.Background() @@ -194,11 +190,7 @@ func TestTodoistClient_CompleteTask_Error(t *testing.T) { defer server.Close() // Create client - client := &TodoistClient{ - apiKey: "test-key", - baseURL: server.URL, - httpClient: &http.Client{}, - } + client := newTestTodoistClient(server.URL, "test-key") // Test CompleteTask with error ctx := context.Background() @@ -235,11 +227,7 @@ func TestTodoistClient_GetProjects(t *testing.T) { defer server.Close() // Create client - client := &TodoistClient{ - apiKey: "test-key", - baseURL: server.URL, - httpClient: &http.Client{}, - } + client := newTestTodoistClient(server.URL, "test-key") // Test GetProjects ctx := context.Background() diff --git a/internal/api/trello.go b/internal/api/trello.go index 4c4dc95..4cf7e9e 100644 --- a/internal/api/trello.go +++ b/internal/api/trello.go @@ -2,44 +2,41 @@ package api import ( "context" - "encoding/json" "fmt" - "io" "log" - "net/http" "net/url" "sort" - "strings" "sync" "time" "task-dashboard/internal/models" ) -const ( - trelloBaseURL = "https://api.trello.com/1" -) +const trelloBaseURL = "https://api.trello.com/1" // TrelloClient handles interactions with the Trello API type TrelloClient struct { - apiKey string - token string - baseURL string - httpClient *http.Client + BaseClient + apiKey string + token string } // NewTrelloClient creates a new Trello API client func NewTrelloClient(apiKey, token string) *TrelloClient { return &TrelloClient{ - apiKey: apiKey, - token: token, - baseURL: trelloBaseURL, - httpClient: &http.Client{ - Timeout: 15 * time.Second, - }, + BaseClient: NewBaseClient(trelloBaseURL), + apiKey: apiKey, + token: token, } } +func (c *TrelloClient) authParams() url.Values { + params := url.Values{} + params.Set("key", c.apiKey) + params.Set("token", c.token) + return params +} + // trelloBoardResponse represents a board from the API type trelloBoardResponse struct { ID string `json:"id"` @@ -65,43 +62,22 @@ type trelloListResponse struct { // GetBoards fetches all boards for the authenticated user func (c *TrelloClient) GetBoards(ctx context.Context) ([]models.Board, error) { - params := url.Values{} - params.Set("key", c.apiKey) - params.Set("token", c.token) + params := c.authParams() params.Set("filter", "open") - params.Set("fields", "id,name") // Only fetch required fields - - reqURL := fmt.Sprintf("%s/members/me/boards?%s", c.baseURL, params.Encode()) - req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to fetch boards: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("trello API error (status %d): %s", resp.StatusCode, string(body)) - } + params.Set("fields", "id,name") var apiBoards []trelloBoardResponse - if err := json.NewDecoder(resp.Body).Decode(&apiBoards); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + if err := c.Get(ctx, "/members/me/boards?"+params.Encode(), nil, &apiBoards); err != nil { + return nil, fmt.Errorf("failed to fetch boards: %w", err) } - // Convert to our model boards := make([]models.Board, 0, len(apiBoards)) for _, apiBoard := range apiBoards { - board := models.Board{ + boards = append(boards, models.Board{ ID: apiBoard.ID, Name: apiBoard.Name, - Cards: []models.Card{}, // Will be populated by GetCards - } - boards = append(boards, board) + Cards: []models.Card{}, + }) } return boards, nil @@ -109,32 +85,14 @@ func (c *TrelloClient) GetBoards(ctx context.Context) ([]models.Board, error) { // GetCards fetches all cards for a specific board func (c *TrelloClient) GetCards(ctx context.Context, boardID string) ([]models.Card, error) { - params := url.Values{} - params.Set("key", c.apiKey) - params.Set("token", c.token) + params := c.authParams() params.Set("filter", "visible") - params.Set("fields", "id,name,idList,due,url,idBoard") // Only fetch required fields - - reqURL := fmt.Sprintf("%s/boards/%s/cards?%s", c.baseURL, boardID, params.Encode()) - req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to fetch cards: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("trello API error (status %d): %s", resp.StatusCode, string(body)) - } + params.Set("fields", "id,name,idList,due,url,idBoard") var apiCards []trelloCardResponse - if err := json.NewDecoder(resp.Body).Decode(&apiCards); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + path := fmt.Sprintf("/boards/%s/cards?%s", boardID, params.Encode()) + if err := c.Get(ctx, path, nil, &apiCards); err != nil { + return nil, fmt.Errorf("failed to fetch cards: %w", err) } log.Printf("Trello GetCards: board %s returned %d cards from API", boardID, len(apiCards)) @@ -143,15 +101,13 @@ func (c *TrelloClient) GetCards(ctx context.Context, boardID string) ([]models.C lists, err := c.getLists(ctx, boardID) listMap := make(map[string]string) if err != nil { - log.Printf("Warning: failed to fetch lists for board %s, cards will have empty list names: %v", boardID, err) + log.Printf("Warning: failed to fetch lists for board %s: %v", boardID, err) } else { - // Build map of list ID to name for _, list := range lists { listMap[list.ID] = list.Name } } - // Convert to our model cards := make([]models.Card, 0, len(apiCards)) for _, apiCard := range apiCards { card := models.Card{ @@ -162,10 +118,8 @@ func (c *TrelloClient) GetCards(ctx context.Context, boardID string) ([]models.C URL: apiCard.URL, } - // Parse due date if present if apiCard.Due != nil && *apiCard.Due != "" { - dueDate, err := time.Parse(time.RFC3339, *apiCard.Due) - if err == nil { + if dueDate, err := time.Parse(time.RFC3339, *apiCard.Due); err == nil { card.DueDate = &dueDate } } @@ -178,34 +132,15 @@ func (c *TrelloClient) GetCards(ctx context.Context, boardID string) ([]models.C // getLists fetches lists for a board func (c *TrelloClient) getLists(ctx context.Context, boardID string) ([]models.List, error) { - params := url.Values{} - params.Set("key", c.apiKey) - params.Set("token", c.token) - params.Set("fields", "id,name") // Only fetch required fields - - reqURL := fmt.Sprintf("%s/boards/%s/lists?%s", c.baseURL, boardID, params.Encode()) - req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to fetch lists: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("trello API error (status %d): %s", resp.StatusCode, string(body)) - } + params := c.authParams() + params.Set("fields", "id,name") var apiLists []trelloListResponse - if err := json.NewDecoder(resp.Body).Decode(&apiLists); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + path := fmt.Sprintf("/boards/%s/lists?%s", boardID, params.Encode()) + if err := c.Get(ctx, path, nil, &apiLists); err != nil { + return nil, fmt.Errorf("failed to fetch lists: %w", err) } - // Convert to model lists := make([]models.List, 0, len(apiLists)) for _, apiList := range apiLists { lists = append(lists, models.List{ @@ -222,7 +157,7 @@ func (c *TrelloClient) GetLists(ctx context.Context, boardID string) ([]models.L return c.getLists(ctx, boardID) } -// GetBoardsWithCards fetches all boards and their cards in one call +// GetBoardsWithCards fetches all boards and their cards concurrently func (c *TrelloClient) GetBoardsWithCards(ctx context.Context) ([]models.Board, error) { boards, err := c.GetBoards(ctx) if err != nil { @@ -237,24 +172,19 @@ func (c *TrelloClient) GetBoardsWithCards(ctx context.Context) ([]models.Board, go func(i int) { defer wg.Done() - // Acquire semaphore sem <- struct{}{} defer func() { <-sem }() - // Fetch cards cards, err := c.GetCards(ctx, boards[i].ID) if err != nil { log.Printf("Error fetching cards for board %s (%s): %v", boards[i].Name, boards[i].ID, err) } else { - // Set BoardName for each card for j := range cards { cards[j].BoardName = boards[i].Name } - // It is safe to write to specific indices of the slice concurrently boards[i].Cards = cards } - // Fetch lists lists, err := c.getLists(ctx, boards[i].ID) if err != nil { log.Printf("Error fetching lists for board %s: %v", boards[i].Name, err) @@ -266,18 +196,15 @@ func (c *TrelloClient) GetBoardsWithCards(ctx context.Context) ([]models.Board, wg.Wait() - // Sort boards: Non-empty boards first, newest card activity, then alphabetical by name - // Trello card IDs are chronologically sortable (newer IDs > older IDs) + // Sort boards: Non-empty boards first, newest card activity, then alphabetical sort.Slice(boards, func(i, j int) bool { hasCardsI := len(boards[i].Cards) > 0 hasCardsJ := len(boards[j].Cards) > 0 - // 1. Prioritize boards with cards if hasCardsI != hasCardsJ { - return hasCardsI // true (non-empty) comes before false + return hasCardsI } - // 2. If both have cards, compare by newest card (max ID) if hasCardsI && hasCardsJ { maxIDI := "" for _, card := range boards[i].Cards { @@ -294,11 +221,10 @@ func (c *TrelloClient) GetBoardsWithCards(ctx context.Context) ([]models.Board, } if maxIDI != maxIDJ { - return maxIDI > maxIDJ // Newer (larger) ID comes first + return maxIDI > maxIDJ } } - // 3. Fallback to alphabetical by name return boards[i].Name < boards[j].Name }) @@ -307,48 +233,21 @@ func (c *TrelloClient) GetBoardsWithCards(ctx context.Context) ([]models.Board, // CreateCard creates a new card in the specified list func (c *TrelloClient) CreateCard(ctx context.Context, listID, name, description string, dueDate *time.Time) (*models.Card, error) { - // Prepare request payload - data := url.Values{} - data.Set("key", c.apiKey) - data.Set("token", c.token) + data := c.authParams() data.Set("idList", listID) data.Set("name", name) - if description != "" { data.Set("desc", description) } - if dueDate != nil { data.Set("due", dueDate.Format(time.RFC3339)) } - // Create POST request - reqURL := c.baseURL + "/cards" - req, err := http.NewRequestWithContext(ctx, "POST", reqURL, strings.NewReader(data.Encode())) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - // Execute request - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to create card: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("trello API error (status %d): %s", resp.StatusCode, string(body)) - } - - // Decode response var apiCard trelloCardResponse - if err := json.NewDecoder(resp.Body).Decode(&apiCard); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + if err := c.PostForm(ctx, "/cards", nil, data.Encode(), &apiCard); err != nil { + return nil, fmt.Errorf("failed to create card: %w", err) } - // Convert to our model card := &models.Card{ ID: apiCard.ID, Name: apiCard.Name, @@ -356,10 +255,8 @@ func (c *TrelloClient) CreateCard(ctx context.Context, listID, name, description URL: apiCard.URL, } - // Parse due date if present if apiCard.Due != nil && *apiCard.Due != "" { - parsedDate, err := time.Parse(time.RFC3339, *apiCard.Due) - if err == nil { + if parsedDate, err := time.Parse(time.RFC3339, *apiCard.Due); err == nil { card.DueDate = &parsedDate } } @@ -369,35 +266,15 @@ func (c *TrelloClient) CreateCard(ctx context.Context, listID, name, description // UpdateCard updates a card with the specified changes func (c *TrelloClient) UpdateCard(ctx context.Context, cardID string, updates map[string]interface{}) error { - // Prepare request payload - data := url.Values{} - data.Set("key", c.apiKey) - data.Set("token", c.token) - - // Add updates to payload + data := c.authParams() for key, value := range updates { data.Set(key, fmt.Sprintf("%v", value)) } - // Create PUT request - reqURL := fmt.Sprintf("%s/cards/%s", c.baseURL, cardID) - req, err := http.NewRequestWithContext(ctx, "PUT", reqURL, strings.NewReader(data.Encode())) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - // Execute request - resp, err := c.httpClient.Do(req) - if err != nil { + path := fmt.Sprintf("/cards/%s", cardID) + if err := c.Put(ctx, path, nil, data.Encode(), nil); err != nil { return fmt.Errorf("failed to update card: %w", err) } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("trello API error (status %d): %s", resp.StatusCode, string(body)) - } return nil } diff --git a/internal/api/trello_test.go b/internal/api/trello_test.go index b43b55e..7433ff0 100644 --- a/internal/api/trello_test.go +++ b/internal/api/trello_test.go @@ -12,6 +12,13 @@ import ( "time" ) +// newTestTrelloClient creates a TrelloClient for testing with custom base URL +func newTestTrelloClient(baseURL, apiKey, token string) *TrelloClient { + client := NewTrelloClient(apiKey, token) + client.BaseClient.BaseURL = baseURL + return client +} + func TestTrelloClient_CreateCard(t *testing.T) { // Mock server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -65,12 +72,7 @@ func TestTrelloClient_CreateCard(t *testing.T) { defer server.Close() // Create client with mock server URL - client := &TrelloClient{ - apiKey: "test-key", - token: "test-token", - baseURL: server.URL, - httpClient: &http.Client{}, - } + client := newTestTrelloClient(server.URL, "test-key", "test-token") // Test CreateCard ctx := context.Background() @@ -125,12 +127,7 @@ func TestTrelloClient_CreateCard_WithDueDate(t *testing.T) { defer server.Close() // Create client - client := &TrelloClient{ - apiKey: "test-key", - token: "test-token", - baseURL: server.URL, - httpClient: &http.Client{}, - } + client := newTestTrelloClient(server.URL, "test-key", "test-token") // Test CreateCard with due date ctx := context.Background() @@ -184,12 +181,7 @@ func TestTrelloClient_UpdateCard(t *testing.T) { defer server.Close() // Create client - client := &TrelloClient{ - apiKey: "test-key", - token: "test-token", - baseURL: server.URL, - httpClient: &http.Client{}, - } + client := newTestTrelloClient(server.URL, "test-key", "test-token") // Test UpdateCard ctx := context.Background() @@ -214,12 +206,7 @@ func TestTrelloClient_UpdateCard_Error(t *testing.T) { defer server.Close() // Create client - client := &TrelloClient{ - apiKey: "test-key", - token: "test-token", - baseURL: server.URL, - httpClient: &http.Client{}, - } + client := newTestTrelloClient(server.URL, "test-key", "test-token") // Test UpdateCard with error ctx := context.Background() diff --git a/internal/handlers/cache.go b/internal/handlers/cache.go new file mode 100644 index 0000000..a2f534e --- /dev/null +++ b/internal/handlers/cache.go @@ -0,0 +1,52 @@ +package handlers + +import ( + "context" + "log" + + "task-dashboard/internal/store" +) + +// CacheFetcher defines the interface for fetching and caching data +type CacheFetcher[T any] struct { + Store *store.Store + CacheKey string + TTLMinutes int + Fetch func(ctx context.Context) ([]T, error) + GetFromCache func() ([]T, error) + SaveToCache func([]T) error +} + +// FetchWithCache fetches data from cache or API with fallback +func (cf *CacheFetcher[T]) FetchWithCache(ctx context.Context, forceRefresh bool) ([]T, error) { + // Check cache validity + if !forceRefresh { + valid, err := cf.Store.IsCacheValid(cf.CacheKey) + if err == nil && valid { + return cf.GetFromCache() + } + } + + // Fetch from API + data, err := cf.Fetch(ctx) + if err != nil { + // Try to return cached data even if stale + cachedData, cacheErr := cf.GetFromCache() + if cacheErr == nil && len(cachedData) > 0 { + return cachedData, nil + } + return nil, err + } + + // Save to cache + if err := cf.SaveToCache(data); err != nil { + log.Printf("Failed to save to cache (%s): %v", cf.CacheKey, err) + } + + // Update cache metadata + if err := cf.Store.UpdateCacheMetadata(cf.CacheKey, cf.TTLMinutes); err != nil { + log.Printf("Failed to update cache metadata (%s): %v", cf.CacheKey, err) + } + + return data, nil +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index c364188..126eef1 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -2,7 +2,6 @@ package handlers import ( "context" - "encoding/json" "fmt" "html/template" "log" @@ -105,30 +104,22 @@ func (h *Handler) HandleDashboard(w http.ResponseWriter, r *http.Request) { // HandleRefresh forces a refresh of all data func (h *Handler) HandleRefresh(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - // Force refresh by passing true - data, err := h.aggregateData(ctx, true) + data, err := h.aggregateData(r.Context(), true) if err != nil { - http.Error(w, "Failed to refresh data", http.StatusInternalServerError) - log.Printf("Error refreshing data: %v", err) + JSONError(w, http.StatusInternalServerError, "Failed to refresh data", err) return } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(data) + 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 { - http.Error(w, "Failed to get tasks", http.StatusInternalServerError) + JSONError(w, http.StatusInternalServerError, "Failed to get tasks", err) return } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(tasks) + JSONResponse(w, tasks) } // HandleGetMeals returns meals as JSON @@ -138,62 +129,43 @@ func (h *Handler) HandleGetMeals(w http.ResponseWriter, r *http.Request) { meals, err := h.store.GetMeals(startDate, endDate) if err != nil { - http.Error(w, "Failed to get meals", http.StatusInternalServerError) + JSONError(w, http.StatusInternalServerError, "Failed to get meals", err) return } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(meals) + JSONResponse(w, meals) } // HandleGetBoards returns Trello boards with cards as JSON func (h *Handler) HandleGetBoards(w http.ResponseWriter, r *http.Request) { boards, err := h.store.GetBoards() if err != nil { - http.Error(w, "Failed to get boards", http.StatusInternalServerError) + JSONError(w, http.StatusInternalServerError, "Failed to get boards", err) return } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(boards) + JSONResponse(w, boards) } // HandleTasksTab renders the tasks tab content (Trello + Todoist + PlanToEat) func (h *Handler) HandleTasksTab(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - data, err := h.aggregateData(ctx, false) + data, err := h.aggregateData(r.Context(), false) if err != nil { - http.Error(w, "Failed to load tasks", http.StatusInternalServerError) - log.Printf("Error loading tasks tab: %v", err) + JSONError(w, http.StatusInternalServerError, "Failed to load tasks", err) return } - - if err := h.templates.ExecuteTemplate(w, "tasks-tab", data); err != nil { - http.Error(w, "Failed to render template", http.StatusInternalServerError) - log.Printf("Error rendering tasks tab: %v", err) - } + HTMLResponse(w, h.templates, "tasks-tab", data) } // HandleRefreshTab refreshes and re-renders the specified tab func (h *Handler) HandleRefreshTab(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - // Force refresh - data, err := h.aggregateData(ctx, true) + data, err := h.aggregateData(r.Context(), true) if err != nil { - http.Error(w, "Failed to refresh", http.StatusInternalServerError) - log.Printf("Error refreshing tab: %v", err) + JSONError(w, http.StatusInternalServerError, "Failed to refresh", err) return } - - if err := h.templates.ExecuteTemplate(w, "tasks-tab", data); err != nil { - http.Error(w, "Failed to render template", http.StatusInternalServerError) - log.Printf("Error rendering refreshed tab: %v", err) - } + HTMLResponse(w, h.templates, "tasks-tab", data) } -// aggregateData fetches and caches data from all sources +// aggregateData fetches and caches data from all sources concurrently func (h *Handler) aggregateData(ctx context.Context, forceRefresh bool) (*models.DashboardData, error) { data := &models.DashboardData{ LastUpdated: time.Now(), @@ -203,169 +175,132 @@ func (h *Handler) aggregateData(ctx context.Context, forceRefresh bool) (*models var wg sync.WaitGroup var mu sync.Mutex - // Fetch Trello boards (PRIORITY - most important) - wg.Add(1) - go func() { - defer wg.Done() - select { - case <-ctx.Done(): - return - default: - } + // Helper to run fetch in goroutine with error collection + fetch := func(name string, fn func() error) { + wg.Add(1) + go func() { + defer wg.Done() + select { + case <-ctx.Done(): + return + default: + } + if err := fn(); err != nil { + mu.Lock() + data.Errors = append(data.Errors, name+": "+err.Error()) + mu.Unlock() + } + }() + } + + fetch("Trello", func() error { boards, err := h.fetchBoards(ctx, forceRefresh) - mu.Lock() - defer mu.Unlock() - if err != nil { - data.Errors = append(data.Errors, "Trello: "+err.Error()) - } else { + if err == nil { + mu.Lock() data.Boards = boards + mu.Unlock() } - }() - - // Fetch Todoist tasks - wg.Add(1) - go func() { - defer wg.Done() - select { - case <-ctx.Done(): - return - default: - } - tasks, err := h.fetchTasks(ctx, forceRefresh) - mu.Lock() - defer mu.Unlock() - if err != nil { - data.Errors = append(data.Errors, "Todoist: "+err.Error()) - } else { - // Sort tasks: earliest due date first, nil last, then by priority (descending) - sort.Slice(tasks, func(i, j int) bool { - // Handle nil due dates (push to end) - if tasks[i].DueDate == nil && tasks[j].DueDate != nil { - return false - } - if tasks[i].DueDate != nil && tasks[j].DueDate == nil { - return true - } - - // Both have due dates, sort by date - 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 err + }) - // Same due date (or both nil), sort by priority (descending) - return tasks[i].Priority > tasks[j].Priority - }) + fetch("Todoist", func() error { + tasks, err := h.fetchTasks(ctx, forceRefresh) + if err == nil { + sortTasksByUrgency(tasks) + mu.Lock() data.Tasks = tasks + mu.Unlock() } - }() - - // Fetch Todoist projects - wg.Add(1) - go func() { - defer wg.Done() - select { - case <-ctx.Done(): - return - default: - } + return err + }) + + fetch("Projects", func() error { projects, err := h.todoistClient.GetProjects(ctx) - mu.Lock() - defer mu.Unlock() - if err != nil { - log.Printf("Failed to fetch projects: %v", err) - } else { + if err == nil { + mu.Lock() data.Projects = projects + mu.Unlock() } - }() + return err + }) - // Fetch PlanToEat meals (if configured) if h.planToEatClient != nil { - wg.Add(1) - go func() { - defer wg.Done() - select { - case <-ctx.Done(): - return - default: - } + fetch("PlanToEat", func() error { meals, err := h.fetchMeals(ctx, forceRefresh) - mu.Lock() - defer mu.Unlock() - if err != nil { - data.Errors = append(data.Errors, "PlanToEat: "+err.Error()) - } else { + if err == nil { + mu.Lock() data.Meals = meals + mu.Unlock() } - }() + return err + }) } - // Fetch Google Calendar events (if configured) if h.googleCalendarClient != nil { - wg.Add(1) - go func() { - defer wg.Done() - select { - case <-ctx.Done(): - return - default: - } + fetch("Google Calendar", func() error { events, err := h.googleCalendarClient.GetUpcomingEvents(ctx, 10) - mu.Lock() - defer mu.Unlock() - if err != nil { - data.Errors = append(data.Errors, "Google Calendar: "+err.Error()) - } else { + if err == nil { + mu.Lock() data.Events = events + mu.Unlock() } - }() + return err + }) } wg.Wait() - // Filter Trello cards into tasks based on heuristic - var trelloTasks []models.Card - for _, board := range data.Boards { + // 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 + for _, board := range boards { for _, card := range board.Cards { - listNameLower := strings.ToLower(card.ListName) - isTask := card.DueDate != nil || - strings.Contains(listNameLower, "todo") || - strings.Contains(listNameLower, "doing") || - strings.Contains(listNameLower, "progress") || - strings.Contains(listNameLower, "task") - - if isTask { - trelloTasks = append(trelloTasks, card) + if card.DueDate != nil || isActionableList(card.ListName) { + tasks = append(tasks, card) } } } - // Sort trelloTasks: earliest due date first, nil last, then by board name - sort.Slice(trelloTasks, func(i, j int) bool { - // Both have due dates: compare dates - if trelloTasks[i].DueDate != nil && trelloTasks[j].DueDate != nil { - if !trelloTasks[i].DueDate.Equal(*trelloTasks[j].DueDate) { - return trelloTasks[i].DueDate.Before(*trelloTasks[j].DueDate) + sort.Slice(tasks, func(i, j int) bool { + 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) } - // Same due date, fall through to board name comparison } - - // Only one has due date: that one comes first - if trelloTasks[i].DueDate != nil && trelloTasks[j].DueDate == nil { + if tasks[i].DueDate != nil && tasks[j].DueDate == nil { return true } - if trelloTasks[i].DueDate == nil && trelloTasks[j].DueDate != nil { + if tasks[i].DueDate == nil && tasks[j].DueDate != nil { return false } - - // Both nil or same due date: sort by board name - return trelloTasks[i].BoardName < trelloTasks[j].BoardName + return tasks[i].BoardName < tasks[j].BoardName }) - data.TrelloTasks = trelloTasks - - return data, nil + return tasks } // fetchTasks fetches tasks from cache or API using incremental sync @@ -451,134 +386,74 @@ func (h *Handler) fetchTasks(ctx context.Context, forceRefresh bool) ([]models.T // convertSyncItemToTask converts a sync item to a Task model func (h *Handler) convertSyncItemToTask(item api.SyncItemResponse, projectMap map[string]string) models.Task { - task := models.Task{ + // Use the ConvertSyncItemsToTasks helper for single item conversion + items := api.ConvertSyncItemsToTasks([]api.SyncItemResponse{item}, projectMap) + if len(items) > 0 { + return items[0] + } + // Fallback for completed/deleted items (shouldn't happen in practice) + return models.Task{ ID: item.ID, Content: item.Content, Description: item.Description, ProjectID: item.ProjectID, ProjectName: projectMap[item.ProjectID], Priority: item.Priority, - Completed: false, + Completed: item.IsCompleted, Labels: item.Labels, URL: fmt.Sprintf("https://todoist.com/app/task/%s", item.ID), } - - if item.AddedAt != "" { - if createdAt, err := time.Parse(time.RFC3339, item.AddedAt); err == nil { - task.CreatedAt = createdAt - } - } - - if item.Due != nil { - var dueDate time.Time - var err error - if item.Due.Datetime != "" { - dueDate, err = time.Parse(time.RFC3339, item.Due.Datetime) - } else if item.Due.Date != "" { - dueDate, err = time.Parse("2006-01-02", item.Due.Date) - } - if err == nil { - task.DueDate = &dueDate - } - } - - return task } // fetchMeals fetches meals from cache or API func (h *Handler) fetchMeals(ctx context.Context, forceRefresh bool) ([]models.Meal, error) { - cacheKey := store.CacheKeyPlanToEatMeals - - // Check cache validity - if !forceRefresh { - valid, err := h.store.IsCacheValid(cacheKey) - if err == nil && valid { - startDate := time.Now() - endDate := startDate.AddDate(0, 0, 7) - return h.store.GetMeals(startDate, endDate) - } - } - - // Fetch from API - meals, err := h.planToEatClient.GetUpcomingMeals(ctx, 7) - if err != nil { - // Try to return cached data even if stale - startDate := time.Now() - endDate := startDate.AddDate(0, 0, 7) - cachedMeals, cacheErr := h.store.GetMeals(startDate, endDate) - if cacheErr == nil && len(cachedMeals) > 0 { - return cachedMeals, nil - } - return nil, err - } - - // Save to cache - if err := h.store.SaveMeals(meals); err != nil { - log.Printf("Failed to save meals to cache: %v", err) - } + startDate := time.Now() + endDate := startDate.AddDate(0, 0, 7) - // Update cache metadata - if err := h.store.UpdateCacheMetadata(cacheKey, h.config.CacheTTLMinutes); err != nil { - log.Printf("Failed to update cache metadata: %v", err) + fetcher := &CacheFetcher[models.Meal]{ + Store: h.store, + CacheKey: store.CacheKeyPlanToEatMeals, + TTLMinutes: h.config.CacheTTLMinutes, + Fetch: func(ctx context.Context) ([]models.Meal, error) { return h.planToEatClient.GetUpcomingMeals(ctx, 7) }, + GetFromCache: func() ([]models.Meal, error) { return h.store.GetMeals(startDate, endDate) }, + SaveToCache: h.store.SaveMeals, } - - return meals, nil + return fetcher.FetchWithCache(ctx, forceRefresh) } // fetchBoards fetches Trello boards from cache or API func (h *Handler) fetchBoards(ctx context.Context, forceRefresh bool) ([]models.Board, error) { - cacheKey := store.CacheKeyTrelloBoards - - // Check cache validity - if !forceRefresh { - valid, err := h.store.IsCacheValid(cacheKey) - if err == nil && valid { - return h.store.GetBoards() - } - } - - // Fetch from API - boards, err := h.trelloClient.GetBoardsWithCards(ctx) - if err != nil { - // Try to return cached data even if stale - cachedBoards, cacheErr := h.store.GetBoards() - if cacheErr == nil && len(cachedBoards) > 0 { - return cachedBoards, nil - } - return nil, err - } - - // Debug: log what we got from API - totalCards := 0 - for _, b := range boards { - totalCards += len(b.Cards) - if len(b.Cards) > 0 { - log.Printf("Trello API: Board %q has %d cards", b.Name, len(b.Cards)) - } - } - log.Printf("Trello API: Fetched %d boards with %d total cards", len(boards), totalCards) - - // Save to cache - if err := h.store.SaveBoards(boards); err != nil { - log.Printf("Failed to save boards to cache: %v", err) - } - - // Update cache metadata - if err := h.store.UpdateCacheMetadata(cacheKey, h.config.CacheTTLMinutes); err != nil { - log.Printf("Failed to update cache metadata: %v", err) + fetcher := &CacheFetcher[models.Board]{ + Store: h.store, + CacheKey: store.CacheKeyTrelloBoards, + TTLMinutes: h.config.CacheTTLMinutes, + Fetch: func(ctx context.Context) ([]models.Board, error) { + boards, err := h.trelloClient.GetBoardsWithCards(ctx) + if err == nil { + // Debug logging + totalCards := 0 + for _, b := range boards { + totalCards += len(b.Cards) + if len(b.Cards) > 0 { + log.Printf("Trello API: Board %q has %d cards", b.Name, len(b.Cards)) + } + } + log.Printf("Trello API: Fetched %d boards with %d total cards", len(boards), totalCards) + } + return boards, err + }, + GetFromCache: h.store.GetBoards, + SaveToCache: h.store.SaveBoards, } - - return boards, nil + return fetcher.FetchWithCache(ctx, forceRefresh) } // HandleCreateCard creates a new Trello card func (h *Handler) HandleCreateCard(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - // Parse form data if err := r.ParseForm(); err != nil { - http.Error(w, "Failed to parse form", http.StatusBadRequest) - log.Printf("Error parsing form: %v", err) + JSONError(w, http.StatusBadRequest, "Failed to parse form", err) return } @@ -587,27 +462,21 @@ func (h *Handler) HandleCreateCard(w http.ResponseWriter, r *http.Request) { name := r.FormValue("name") if boardID == "" || listID == "" || name == "" { - http.Error(w, "Missing required fields", http.StatusBadRequest) + JSONError(w, http.StatusBadRequest, "Missing required fields", nil) return } - // Create the card - _, err := h.trelloClient.CreateCard(ctx, listID, name, "", nil) - if err != nil { - http.Error(w, "Failed to create card", http.StatusInternalServerError) - log.Printf("Error creating card: %v", err) + if _, err := h.trelloClient.CreateCard(ctx, listID, name, "", nil); err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to create card", err) return } - // Force refresh to get updated data data, err := h.aggregateData(ctx, true) if err != nil { - http.Error(w, "Failed to refresh data", http.StatusInternalServerError) - log.Printf("Error refreshing data: %v", err) + JSONError(w, http.StatusInternalServerError, "Failed to refresh data", err) return } - // Find the specific board var targetBoard *models.Board for i := range data.Boards { if data.Boards[i].ID == boardID { @@ -617,47 +486,31 @@ func (h *Handler) HandleCreateCard(w http.ResponseWriter, r *http.Request) { } if targetBoard == nil { - http.Error(w, "Board not found", http.StatusNotFound) + JSONError(w, http.StatusNotFound, "Board not found", nil) return } - // Render the updated board - if err := h.templates.ExecuteTemplate(w, "trello-board", targetBoard); err != nil { - http.Error(w, "Failed to render template", http.StatusInternalServerError) - log.Printf("Error rendering board template: %v", err) - } + HTMLResponse(w, h.templates, "trello-board", targetBoard) } // HandleCompleteCard marks a Trello card as complete func (h *Handler) HandleCompleteCard(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - // Parse form data if err := r.ParseForm(); err != nil { - http.Error(w, "Failed to parse form", http.StatusBadRequest) - log.Printf("Error parsing form: %v", err) + JSONError(w, http.StatusBadRequest, "Failed to parse form", err) return } cardID := r.FormValue("card_id") - if cardID == "" { - http.Error(w, "Missing card_id", http.StatusBadRequest) + JSONError(w, http.StatusBadRequest, "Missing card_id", nil) return } - // Mark card as closed (complete) - updates := map[string]interface{}{ - "closed": true, - } - - if err := h.trelloClient.UpdateCard(ctx, cardID, updates); err != nil { - http.Error(w, "Failed to complete card", http.StatusInternalServerError) - log.Printf("Error completing card: %v", err) + if err := h.trelloClient.UpdateCard(r.Context(), cardID, map[string]interface{}{"closed": true}); err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to complete card", err) return } - // Return empty response (card will be removed from DOM) w.WriteHeader(http.StatusOK) } @@ -665,10 +518,8 @@ func (h *Handler) HandleCompleteCard(w http.ResponseWriter, r *http.Request) { func (h *Handler) HandleCreateTask(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - // Parse form data if err := r.ParseForm(); err != nil { - http.Error(w, "Failed to parse form", http.StatusBadRequest) - log.Printf("Error parsing form: %v", err) + JSONError(w, http.StatusBadRequest, "Failed to parse form", err) return } @@ -676,85 +527,68 @@ func (h *Handler) HandleCreateTask(w http.ResponseWriter, r *http.Request) { projectID := r.FormValue("project_id") if content == "" { - http.Error(w, "Missing content", http.StatusBadRequest) + JSONError(w, http.StatusBadRequest, "Missing content", nil) return } - // Create the task - _, err := h.todoistClient.CreateTask(ctx, content, projectID, nil, 0) - if err != nil { - http.Error(w, "Failed to create task", http.StatusInternalServerError) - log.Printf("Error creating task: %v", err) + if _, err := h.todoistClient.CreateTask(ctx, content, projectID, nil, 0); err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to create task", err) return } - // Force refresh to get updated tasks tasks, err := h.fetchTasks(ctx, true) if err != nil { - http.Error(w, "Failed to refresh tasks", http.StatusInternalServerError) - log.Printf("Error refreshing tasks: %v", err) + JSONError(w, http.StatusInternalServerError, "Failed to refresh tasks", err) return } - // Fetch projects for the dropdown - projects, err := h.todoistClient.GetProjects(ctx) - if err != nil { - log.Printf("Failed to fetch projects: %v", err) - projects = []models.Project{} - } + projects, _ := h.todoistClient.GetProjects(ctx) - // Prepare data for template rendering data := struct { Tasks []models.Task Projects []models.Project - }{ - Tasks: tasks, - Projects: projects, - } + }{Tasks: tasks, Projects: projects} - // Render the updated task list - if err := h.templates.ExecuteTemplate(w, "todoist-tasks", data); err != nil { - http.Error(w, "Failed to render template", http.StatusInternalServerError) - log.Printf("Error rendering todoist tasks template: %v", err) - } + HTMLResponse(w, h.templates, "todoist-tasks", data) } // HandleCompleteTask marks a Todoist task as complete func (h *Handler) HandleCompleteTask(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - // Parse form data if err := r.ParseForm(); err != nil { - http.Error(w, "Failed to parse form", http.StatusBadRequest) - log.Printf("Error parsing form: %v", err) + JSONError(w, http.StatusBadRequest, "Failed to parse form", err) return } taskID := r.FormValue("task_id") - if taskID == "" { - http.Error(w, "Missing task_id", http.StatusBadRequest) + JSONError(w, http.StatusBadRequest, "Missing task_id", nil) return } - // Mark task as complete - if err := h.todoistClient.CompleteTask(ctx, taskID); err != nil { - http.Error(w, "Failed to complete task", http.StatusInternalServerError) - log.Printf("Error completing task: %v", err) + if err := h.todoistClient.CompleteTask(r.Context(), taskID); err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to complete task", err) return } - // Return empty response (task will be removed from DOM) 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) +} + +// HandleUncompleteAtom handles reopening a completed task +func (h *Handler) HandleUncompleteAtom(w http.ResponseWriter, r *http.Request) { + h.handleAtomToggle(w, r, false) +} + +// handleAtomToggle handles both complete and uncomplete operations +func (h *Handler) handleAtomToggle(w http.ResponseWriter, r *http.Request, complete bool) { ctx := r.Context() if err := r.ParseForm(); err != nil { - http.Error(w, "Failed to parse form", http.StatusBadRequest) - log.Printf("Error parsing form: %v", err) + JSONError(w, http.StatusBadRequest, "Failed to parse form", err) return } @@ -762,74 +596,48 @@ func (h *Handler) HandleCompleteAtom(w http.ResponseWriter, r *http.Request) { source := r.FormValue("source") if id == "" || source == "" { - http.Error(w, "Missing id or source", http.StatusBadRequest) + JSONError(w, http.StatusBadRequest, "Missing id or source", nil) return } var err error switch source { case "todoist": - err = h.todoistClient.CompleteTask(ctx, id) - case "trello": - // Archive the card (closed = true) - updates := map[string]interface{}{ - "closed": true, + if complete { + err = h.todoistClient.CompleteTask(ctx, id) + } else { + err = h.todoistClient.ReopenTask(ctx, id) } - err = h.trelloClient.UpdateCard(ctx, id, updates) + case "trello": + err = h.trelloClient.UpdateCard(ctx, id, map[string]interface{}{"closed": complete}) default: - http.Error(w, "Unknown source: "+source, http.StatusBadRequest) + JSONError(w, http.StatusBadRequest, "Unknown source: "+source, nil) return } if err != nil { - http.Error(w, "Failed to complete task", http.StatusInternalServerError) - log.Printf("Error completing atom (source=%s, id=%s): %v", source, id, err) + action := "complete" + if !complete { + action = "reopen" + } + JSONError(w, http.StatusInternalServerError, "Failed to "+action+" task", err) return } - // Get task title before removing from cache - var title string - switch source { - case "todoist": - if tasks, err := h.store.GetTasks(); err == nil { - for _, t := range tasks { - if t.ID == id { - title = t.Content - break - } - } - } - case "trello": - if boards, err := h.store.GetBoards(); err == nil { - for _, b := range boards { - for _, c := range b.Cards { - if c.ID == id { - title = c.Name - break - } - } - } - } - } - if title == "" { - title = "Task" - } + if complete { + // Get task title before removing from cache + title := h.getAtomTitle(id, source) - // Remove from local cache - switch source { - case "todoist": - if err := h.store.DeleteTask(id); err != nil { - log.Printf("Warning: failed to delete task from cache: %v", err) + // Remove from local cache + switch source { + case "todoist": + h.store.DeleteTask(id) + case "trello": + h.store.DeleteCard(id) } - case "trello": - if err := h.store.DeleteCard(id); err != nil { - log.Printf("Warning: failed to delete card from cache: %v", err) - } - } - // Return completed task HTML with uncomplete option - w.Header().Set("Content-Type", "text/html") - completedHTML := fmt.Sprintf(`<div class="bg-white/5 rounded-lg border border-white/5 opacity-60"> + // Return completed task HTML with uncomplete option + completedHTML := fmt.Sprintf(`<div class="bg-white/5 rounded-lg border border-white/5 opacity-60"> <div class="flex items-start gap-2 sm:gap-3 p-3 sm:p-4"> <input type="checkbox" checked hx-post="/uncomplete-atom" @@ -844,59 +652,43 @@ func (h *Handler) HandleCompleteAtom(w http.ResponseWriter, r *http.Request) { </div> </div> </div>`, template.HTMLEscapeString(id), template.HTMLEscapeString(source), template.HTMLEscapeString(title)) - w.Write([]byte(completedHTML)) -} - -// HandleUncompleteAtom handles reopening a completed task -func (h *Handler) HandleUncompleteAtom(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - if err := r.ParseForm(); err != nil { - http.Error(w, "Failed to parse form", http.StatusBadRequest) - log.Printf("Error parsing form: %v", err) - return - } - - id := r.FormValue("id") - source := r.FormValue("source") - - if id == "" || source == "" { - http.Error(w, "Missing id or source", http.StatusBadRequest) - return - } - - var err error - switch source { - case "todoist": - err = h.todoistClient.ReopenTask(ctx, id) - case "trello": - // Reopen the card (closed = false) - updates := map[string]interface{}{ - "closed": false, + HTMLString(w, completedHTML) + } else { + // Invalidate cache to force refresh + switch source { + case "todoist": + h.store.InvalidateCache(store.CacheKeyTodoistTasks) + case "trello": + h.store.InvalidateCache(store.CacheKeyTrelloBoards) } - err = h.trelloClient.UpdateCard(ctx, id, updates) - default: - http.Error(w, "Unknown source: "+source, http.StatusBadRequest) - return - } - - if err != nil { - http.Error(w, "Failed to reopen task", http.StatusInternalServerError) - log.Printf("Error reopening atom (source=%s, id=%s): %v", source, id, err) - return + w.Header().Set("HX-Trigger", "refresh-tasks") + w.WriteHeader(http.StatusOK) } +} - // Invalidate cache to force refresh +// getAtomTitle retrieves the title for a task/card from the store +func (h *Handler) getAtomTitle(id, source string) string { switch source { case "todoist": - h.store.InvalidateCache(store.CacheKeyTodoistTasks) + if tasks, err := h.store.GetTasks(); err == nil { + for _, t := range tasks { + if t.ID == id { + return t.Content + } + } + } case "trello": - h.store.InvalidateCache(store.CacheKeyTrelloBoards) + if boards, err := h.store.GetBoards(); err == nil { + for _, b := range boards { + for _, c := range b.Cards { + if c.ID == id { + return c.Name + } + } + } + } } - - // Trigger refresh - w.Header().Set("HX-Trigger", "refresh-tasks") - w.WriteHeader(http.StatusOK) + return "Task" } // HandleUnifiedAdd creates a task in Todoist or a card in Trello from the Quick Add form @@ -904,7 +696,7 @@ func (h *Handler) HandleUnifiedAdd(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if err := r.ParseForm(); err != nil { - http.Error(w, "Failed to parse form", http.StatusBadRequest) + JSONError(w, http.StatusBadRequest, "Failed to parse form", err) return } @@ -913,69 +705,57 @@ func (h *Handler) HandleUnifiedAdd(w http.ResponseWriter, r *http.Request) { dueDateStr := r.FormValue("due_date") if title == "" { - http.Error(w, "Title is required", http.StatusBadRequest) + JSONError(w, http.StatusBadRequest, "Title is required", nil) return } - // Parse due date if provided (use local timezone) var dueDate *time.Time if dueDateStr != "" { - parsed, err := time.ParseInLocation("2006-01-02", dueDateStr, time.Local) - if err == nil { + if parsed, err := time.ParseInLocation("2006-01-02", dueDateStr, time.Local); err == nil { dueDate = &parsed } } switch source { case "todoist": - _, err := h.todoistClient.CreateTask(ctx, title, "", dueDate, 1) - if err != nil { - http.Error(w, "Failed to create Todoist task", http.StatusInternalServerError) - log.Printf("Error creating Todoist task: %v", err) + if _, err := h.todoistClient.CreateTask(ctx, title, "", dueDate, 1); err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to create Todoist task", err) return } - // Invalidate cache so fresh data is fetched h.store.InvalidateCache(store.CacheKeyTodoistTasks) case "trello": listID := r.FormValue("list_id") if listID == "" { - http.Error(w, "List is required for Trello", http.StatusBadRequest) + JSONError(w, http.StatusBadRequest, "List is required for Trello", nil) return } - _, err := h.trelloClient.CreateCard(ctx, listID, title, "", dueDate) - if err != nil { - http.Error(w, "Failed to create Trello card", http.StatusInternalServerError) - log.Printf("Error creating Trello card: %v", err) + if _, err := h.trelloClient.CreateCard(ctx, listID, title, "", dueDate); err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to create Trello card", err) return } - // Invalidate cache so fresh data is fetched h.store.InvalidateCache(store.CacheKeyTrelloBoards) default: - http.Error(w, "Invalid source", http.StatusBadRequest) + JSONError(w, http.StatusBadRequest, "Invalid source", nil) return } - // Trigger a refresh of the tasks tab via HTMX w.Header().Set("HX-Trigger", "refresh-tasks") w.WriteHeader(http.StatusOK) } // HandleGetListsOptions returns HTML options for lists in a given board func (h *Handler) HandleGetListsOptions(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() boardID := r.URL.Query().Get("board_id") - if boardID == "" { - http.Error(w, "board_id is required", http.StatusBadRequest) + JSONError(w, http.StatusBadRequest, "board_id is required", nil) return } - lists, err := h.trelloClient.GetLists(ctx, boardID) + lists, err := h.trelloClient.GetLists(r.Context(), boardID) if err != nil { - http.Error(w, "Failed to fetch lists", http.StatusInternalServerError) - log.Printf("Error fetching lists for board %s: %v", boardID, err) + JSONError(w, http.StatusInternalServerError, "Failed to fetch lists", err) return } @@ -989,8 +769,7 @@ func (h *Handler) HandleGetListsOptions(w http.ResponseWriter, r *http.Request) func (h *Handler) HandleGetBugs(w http.ResponseWriter, r *http.Request) { bugs, err := h.store.GetBugs() if err != nil { - http.Error(w, "Failed to fetch bugs", http.StatusInternalServerError) - log.Printf("Error fetching bugs: %v", err) + JSONError(w, http.StatusInternalServerError, "Failed to fetch bugs", err) return } @@ -1011,23 +790,21 @@ func (h *Handler) HandleGetBugs(w http.ResponseWriter, r *http.Request) { // HandleReportBug saves a new bug report func (h *Handler) HandleReportBug(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { - http.Error(w, "Invalid form data", http.StatusBadRequest) + JSONError(w, http.StatusBadRequest, "Invalid form data", err) return } description := strings.TrimSpace(r.FormValue("description")) if description == "" { - http.Error(w, "Description is required", http.StatusBadRequest) + JSONError(w, http.StatusBadRequest, "Description is required", nil) return } if err := h.store.SaveBug(description); err != nil { - http.Error(w, "Failed to save bug", http.StatusInternalServerError) - log.Printf("Error saving bug: %v", err) + JSONError(w, http.StatusInternalServerError, "Failed to save bug", err) return } - // Return updated bug list h.HandleGetBugs(w, r) } @@ -1037,32 +814,27 @@ func (h *Handler) HandleGetTaskDetail(w http.ResponseWriter, r *http.Request) { source := r.URL.Query().Get("source") if id == "" || source == "" { - http.Error(w, "Missing id or source", http.StatusBadRequest) + JSONError(w, http.StatusBadRequest, "Missing id or source", nil) return } var title, description string switch source { case "todoist": - tasks, err := h.store.GetTasks() - if err == nil { + if tasks, err := h.store.GetTasks(); err == nil { for _, t := range tasks { if t.ID == id { - title = t.Content - description = t.Description + title, description = t.Content, t.Description break } } } case "trello": - boards, err := h.store.GetBoards() - if err == nil { + if boards, err := h.store.GetBoards(); err == nil { for _, b := range boards { for _, c := range b.Cards { if c.ID == id { title = c.Name - // Card model doesn't store description, leave empty - description = "" break } } @@ -1070,7 +842,6 @@ func (h *Handler) HandleGetTaskDetail(w http.ResponseWriter, r *http.Request) { } } - w.Header().Set("Content-Type", "text/html") html := fmt.Sprintf(` <div class="p-4"> <h3 class="font-semibold text-gray-900 mb-3">%s</h3> @@ -1083,18 +854,17 @@ func (h *Handler) HandleGetTaskDetail(w http.ResponseWriter, r *http.Request) { </form> </div> `, template.HTMLEscapeString(title), template.HTMLEscapeString(id), template.HTMLEscapeString(source), template.HTMLEscapeString(description)) - w.Write([]byte(html)) + HTMLString(w, html) } // HandleGetShoppingLists returns the lists from the Shopping board for quick-add func (h *Handler) HandleGetShoppingLists(w http.ResponseWriter, r *http.Request) { boards, err := h.store.GetBoards() if err != nil { - http.Error(w, "Failed to get boards", http.StatusInternalServerError) + JSONError(w, http.StatusInternalServerError, "Failed to get boards", err) return } - // Find the Shopping board var shoppingBoardID string for _, b := range boards { if strings.EqualFold(b.Name, "Shopping") { @@ -1104,15 +874,13 @@ func (h *Handler) HandleGetShoppingLists(w http.ResponseWriter, r *http.Request) } if shoppingBoardID == "" { - http.Error(w, "Shopping board not found", http.StatusNotFound) + JSONError(w, http.StatusNotFound, "Shopping board not found", nil) return } - // Get lists for the shopping board lists, err := h.trelloClient.GetLists(r.Context(), shoppingBoardID) if err != nil { - http.Error(w, "Failed to get lists", http.StatusInternalServerError) - log.Printf("Error fetching shopping lists: %v", err) + JSONError(w, http.StatusInternalServerError, "Failed to get lists", err) return } @@ -1124,10 +892,8 @@ func (h *Handler) HandleGetShoppingLists(w http.ResponseWriter, r *http.Request) // HandleUpdateTask updates a task description func (h *Handler) HandleUpdateTask(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - if err := r.ParseForm(); err != nil { - http.Error(w, "Failed to parse form", http.StatusBadRequest) + JSONError(w, http.StatusBadRequest, "Failed to parse form", err) return } @@ -1136,28 +902,291 @@ func (h *Handler) HandleUpdateTask(w http.ResponseWriter, r *http.Request) { description := r.FormValue("description") if id == "" || source == "" { - http.Error(w, "Missing id or source", http.StatusBadRequest) + JSONError(w, http.StatusBadRequest, "Missing id or source", nil) return } var err error switch source { case "todoist": - updates := map[string]interface{}{"description": description} - err = h.todoistClient.UpdateTask(ctx, id, updates) + err = h.todoistClient.UpdateTask(r.Context(), id, map[string]interface{}{"description": description}) case "trello": - updates := map[string]interface{}{"desc": description} - err = h.trelloClient.UpdateCard(ctx, id, updates) + err = h.trelloClient.UpdateCard(r.Context(), id, map[string]interface{}{"desc": description}) default: - http.Error(w, "Unknown source", http.StatusBadRequest) + JSONError(w, http.StatusBadRequest, "Unknown source", nil) return } if err != nil { - http.Error(w, "Failed to update task", http.StatusInternalServerError) - log.Printf("Error updating task: %v", err) + JSONError(w, http.StatusInternalServerError, "Failed to update task", err) return } w.WriteHeader(http.StatusOK) } + +// HandleTabTasks renders the unified Tasks tab (Todoist + Trello cards with due dates) +func (h *Handler) HandleTabTasks(w http.ResponseWriter, r *http.Request) { + tasks, err := h.store.GetTasks() + if err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to fetch tasks", err) + return + } + + boards, err := h.store.GetBoards() + if err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to fetch boards", err) + return + } + + atoms := make([]models.Atom, 0) + + for _, task := range tasks { + if !task.Completed { + atoms = append(atoms, models.TaskToAtom(task)) + } + } + + for _, board := range boards { + for _, card := range board.Cards { + if card.DueDate != nil || isActionableList(card.ListName) { + atoms = append(atoms, models.CardToAtom(card)) + } + } + } + + for i := range atoms { + atoms[i].ComputeUIFields() + } + + sort.SliceStable(atoms, func(i, j int) bool { + tierI := atomUrgencyTier(atoms[i]) + tierJ := atomUrgencyTier(atoms[j]) + + if tierI != tierJ { + return tierI < tierJ + } + + if atoms[i].DueDate != nil && atoms[j].DueDate != nil { + if !atoms[i].DueDate.Equal(*atoms[j].DueDate) { + return atoms[i].DueDate.Before(*atoms[j].DueDate) + } + } + + return atoms[i].Priority > atoms[j].Priority + }) + + var currentAtoms, futureAtoms []models.Atom + for _, a := range atoms { + if a.IsFuture { + futureAtoms = append(futureAtoms, a) + } else { + currentAtoms = append(currentAtoms, a) + } + } + + data := struct { + Atoms []models.Atom + FutureAtoms []models.Atom + Boards []models.Board + Today string + }{ + Atoms: currentAtoms, + FutureAtoms: futureAtoms, + Boards: boards, + Today: time.Now().Format("2006-01-02"), + } + + HTMLResponse(w, h.templates, "tasks-tab", data) +} + +// HandleTabPlanning renders the Planning tab with structured sections +func (h *Handler) HandleTabPlanning(w http.ResponseWriter, r *http.Request) { + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + tomorrow := today.AddDate(0, 0, 1) + in3Days := today.AddDate(0, 0, 4) + + boards, _ := h.store.GetBoards() + tasks, _ := h.store.GetTasks() + + var events []models.CalendarEvent + if h.googleCalendarClient != nil { + events, _ = h.googleCalendarClient.GetUpcomingEvents(r.Context(), 20) + } + + var scheduled []ScheduledItem + var unscheduled []models.Atom + var upcoming []ScheduledItem + + for _, event := range events { + item := ScheduledItem{ + Type: "event", + ID: event.ID, + Title: event.Summary, + Start: event.Start, + End: event.End, + URL: event.HTMLLink, + Source: "calendar", + SourceIcon: "📅", + } + + if event.Start.Before(tomorrow) { + scheduled = append(scheduled, item) + } else if event.Start.Before(in3Days) { + upcoming = append(upcoming, item) + } + } + + 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 { + continue + } + dueDate := *card.DueDate + hasTime := dueDate.Hour() != 0 || dueDate.Minute() != 0 + + if dueDate.Before(tomorrow) { + if hasTime { + scheduled = append(scheduled, ScheduledItem{ + Type: "task", + ID: card.ID, + Title: card.Name, + Start: dueDate, + URL: card.URL, + Source: "trello", + SourceIcon: "📋", + }) + } else { + atom := models.CardToAtom(card) + atom.ComputeUIFields() + unscheduled = append(unscheduled, atom) + } + } else if dueDate.Before(in3Days) { + upcoming = append(upcoming, ScheduledItem{ + Type: "task", + ID: card.ID, + Title: card.Name, + Start: dueDate, + URL: card.URL, + Source: "trello", + SourceIcon: "📋", + }) + } + } + } + + sort.Slice(scheduled, func(i, j int) bool { return scheduled[i].Start.Before(scheduled[j].Start) }) + 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) }) + + data := struct { + Scheduled []ScheduledItem + Unscheduled []models.Atom + Upcoming []ScheduledItem + Boards []models.Board + Today string + }{ + Scheduled: scheduled, + Unscheduled: unscheduled, + Upcoming: upcoming, + Boards: boards, + Today: today.Format("2006-01-02"), + } + + HTMLResponse(w, h.templates, "planning-tab", data) +} + +// HandleTabMeals renders the Meals tab (PlanToEat) +func (h *Handler) HandleTabMeals(w http.ResponseWriter, r *http.Request) { + startDate := time.Now() + endDate := startDate.AddDate(0, 0, 7) + + meals, err := h.store.GetMeals(startDate, endDate) + if err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to fetch meals", err) + return + } + + HTMLResponse(w, h.templates, "meals-tab", struct{ Meals []models.Meal }{meals}) +} + +// isActionableList returns true if the list name indicates an actionable list +func isActionableList(name string) bool { + lower := strings.ToLower(name) + return strings.Contains(lower, "doing") || + strings.Contains(lower, "in progress") || + strings.Contains(lower, "to do") || + strings.Contains(lower, "todo") || + strings.Contains(lower, "tasks") || + strings.Contains(lower, "next") || + strings.Contains(lower, "today") +} + +// atomUrgencyTier returns the urgency tier for sorting +func atomUrgencyTier(a models.Atom) int { + if a.DueDate == nil { + return 4 + } + if a.IsOverdue { + return 0 + } + if a.IsFuture { + return 3 + } + if a.HasSetTime { + return 1 + } + return 2 +} + +// ScheduledItem represents a scheduled event or task for the planning view +type ScheduledItem struct { + Type string + ID string + Title string + Start time.Time + End time.Time + URL string + Source string + SourceIcon string + Priority int +} diff --git a/internal/handlers/heuristic_test.go b/internal/handlers/heuristic_test.go index b03b664..82f4e90 100644 --- a/internal/handlers/heuristic_test.go +++ b/internal/handlers/heuristic_test.go @@ -1,97 +1,109 @@ package handlers import ( - "net/http/httptest" - "os" - "strings" "testing" "time" "task-dashboard/internal/models" - "task-dashboard/internal/store" ) -func TestHandleTasks_Heuristic(t *testing.T) { - // Create temp database file - tmpFile, err := os.CreateTemp("", "test_heuristic_*.db") - if err != nil { - t.Fatalf("Failed to create temp db: %v", err) +func TestIsActionableList(t *testing.T) { + tests := []struct { + name string + listName string + want bool + }{ + {"doing list", "Doing", true}, + {"in progress", "In Progress", true}, + {"to do", "To Do", true}, + {"todo", "todo", true}, + {"tasks", "My Tasks", true}, + {"next", "Next Up", true}, + {"today", "Today", true}, + {"backlog", "Backlog", false}, + {"done", "Done", false}, + {"ideas", "Ideas", false}, } - tmpFile.Close() - defer os.Remove(tmpFile.Name()) - // Save current directory and change to project root - originalDir, err := os.Getwd() - if err != nil { - t.Fatalf("Failed to get working directory: %v", err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isActionableList(tt.listName); got != tt.want { + t.Errorf("isActionableList(%q) = %v, want %v", tt.listName, got, tt.want) + } + }) } +} - // Change to project root (2 levels up from internal/handlers) - if err := os.Chdir("../../"); err != nil { - t.Fatalf("Failed to change to project root: %v", err) - } - defer os.Chdir(originalDir) - - // Initialize store (this runs migrations) - db, err := store.New(tmpFile.Name(), "migrations") - if err != nil { - t.Fatalf("Failed to initialize store: %v", err) - } - defer db.Close() - - // Seed Data - // Board 1: Has actionable lists - board1 := models.Board{ID: "b1", Name: "Work Board"} - - // Card 1: Has Due Date (Should appear) +func TestFilterAndSortTrelloTasks(t *testing.T) { due := time.Now() - card1 := models.Card{ID: "c1", Name: "Due Task", ListID: "l1", ListName: "Backlog", DueDate: &due, BoardName: "Work Board"} - - // Card 2: No Due Date, Actionable List (Should appear) - card2 := models.Card{ID: "c2", Name: "Doing Task", ListID: "l2", ListName: "Doing", BoardName: "Work Board"} - - // Card 3: No Due Date, Non-Actionable List (Should NOT appear) - card3 := models.Card{ID: "c3", Name: "Backlog Task", ListID: "l1", ListName: "Backlog", BoardName: "Work Board"} - - // Card 4: No Due Date, "To Do" List (Should appear) - card4 := models.Card{ID: "c4", Name: "Todo Task", ListID: "l3", ListName: "To Do", BoardName: "Work Board"} - - board1.Cards = []models.Card{card1, card2, card3, card4} - - if err := db.SaveBoards([]models.Board{board1}); err != nil { - t.Fatalf("Failed to save boards: %v", err) + boards := []models.Board{ + { + ID: "b1", + Name: "Work Board", + Cards: []models.Card{ + {ID: "c1", Name: "Due Task", ListName: "Backlog", DueDate: &due, BoardName: "Work Board"}, + {ID: "c2", Name: "Doing Task", ListName: "Doing", BoardName: "Work Board"}, + {ID: "c3", Name: "Backlog Task", ListName: "Backlog", BoardName: "Work Board"}, + {ID: "c4", Name: "Todo Task", ListName: "To Do", BoardName: "Work Board"}, + }, + }, } - // Create Handler - h := NewTabsHandler(db, nil, "../../web/templates") + tasks := filterAndSortTrelloTasks(boards) - // Skip if templates are not loaded - if h.templates == nil { - t.Skip("Templates not available in test environment") + // Should have 3 tasks: c1 (has due date), c2 (Doing list), c4 (To Do list) + if len(tasks) != 3 { + t.Errorf("Expected 3 tasks, got %d", len(tasks)) } - req := httptest.NewRequest("GET", "/tabs/tasks", nil) - w := httptest.NewRecorder() - - // Execute - h.HandleTasks(w, req) + // Verify c3 (Backlog without due date) is not included + for _, task := range tasks { + if task.ID == "c3" { + t.Error("Backlog task without due date should not be included") + } + } - // Verify - resp := w.Body.String() + // Verify expected tasks are present + found := map[string]bool{} + for _, task := range tasks { + found[task.ID] = true + } - // Check for presence of expected tasks - if !strings.Contains(resp, "Due Task") { - t.Errorf("Expected 'Due Task' to be present") + if !found["c1"] { + t.Error("Expected c1 (Due Task) to be present") + } + if !found["c2"] { + t.Error("Expected c2 (Doing Task) to be present") } - if !strings.Contains(resp, "Doing Task") { - t.Errorf("Expected 'Doing Task' to be present") + if !found["c4"] { + t.Error("Expected c4 (Todo Task) to be present") } - if !strings.Contains(resp, "Todo Task") { - t.Errorf("Expected 'Todo Task' to be present") +} + +func TestAtomUrgencyTier(t *testing.T) { + now := time.Now() + yesterday := now.AddDate(0, 0, -1) + tomorrow := now.AddDate(0, 0, 1) + todayWithTime := time.Date(now.Year(), now.Month(), now.Day(), 14, 30, 0, 0, now.Location()) + todayMidnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + + tests := []struct { + name string + atom models.Atom + want int + }{ + {"no due date", models.Atom{DueDate: nil}, 4}, + {"overdue", models.Atom{DueDate: &yesterday, IsOverdue: true}, 0}, + {"future", models.Atom{DueDate: &tomorrow, IsFuture: true}, 3}, + {"today with time", models.Atom{DueDate: &todayWithTime, HasSetTime: true}, 1}, + {"today all-day", models.Atom{DueDate: &todayMidnight, HasSetTime: false}, 2}, } - // Check for absence of non-expected tasks - if strings.Contains(resp, "Backlog Task") { - t.Errorf("Expected 'Backlog Task' to be ABSENT") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := atomUrgencyTier(tt.atom); got != tt.want { + t.Errorf("atomUrgencyTier() = %v, want %v", got, tt.want) + } + }) } } diff --git a/internal/handlers/response.go b/internal/handlers/response.go new file mode 100644 index 0000000..3976f02 --- /dev/null +++ b/internal/handlers/response.go @@ -0,0 +1,36 @@ +package handlers + +import ( + "encoding/json" + "html/template" + "log" + "net/http" +) + +// JSONResponse writes data as JSON with appropriate headers +func JSONResponse(w http.ResponseWriter, data interface{}) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(data) +} + +// JSONError writes an error response as JSON +func JSONError(w http.ResponseWriter, status int, msg string, err error) { + if err != nil { + log.Printf("Error: %s: %v", msg, err) + } + http.Error(w, msg, status) +} + +// HTMLResponse renders an HTML template +func HTMLResponse(w http.ResponseWriter, tmpl *template.Template, name string, data interface{}) { + if err := tmpl.ExecuteTemplate(w, name, data); err != nil { + http.Error(w, "Failed to render template", http.StatusInternalServerError) + log.Printf("Error rendering template %s: %v", name, err) + } +} + +// HTMLString writes an HTML string directly +func HTMLString(w http.ResponseWriter, html string) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(html)) +} diff --git a/internal/handlers/tabs.go b/internal/handlers/tabs.go deleted file mode 100644 index 87be344..0000000 --- a/internal/handlers/tabs.go +++ /dev/null @@ -1,384 +0,0 @@ -package handlers - -import ( - "html/template" - "log" - "net/http" - "path/filepath" - "sort" - "strings" - "time" - - "task-dashboard/internal/api" - "task-dashboard/internal/models" - "task-dashboard/internal/store" -) - -// isActionableList returns true if the list name indicates an actionable list -func isActionableList(name string) bool { - lower := strings.ToLower(name) - return strings.Contains(lower, "doing") || - strings.Contains(lower, "in progress") || - strings.Contains(lower, "to do") || - strings.Contains(lower, "todo") || - strings.Contains(lower, "tasks") || - strings.Contains(lower, "next") || - strings.Contains(lower, "today") -} - -// atomUrgencyTier returns the urgency tier for sorting: -// 0: Overdue, 1: Today with time, 2: Today all-day, 3: Future, 4: No due date -func atomUrgencyTier(a models.Atom) int { - if a.DueDate == nil { - return 4 // No due date - } - if a.IsOverdue { - return 0 // Overdue - } - if a.IsFuture { - return 3 // Future - } - // Due today - if a.HasSetTime { - return 1 // Today with specific time - } - return 2 // Today all-day -} - -// TabsHandler handles tab-specific rendering with Atom model -type TabsHandler struct { - store *store.Store - googleCalendarClient api.GoogleCalendarAPI - templates *template.Template -} - -// NewTabsHandler creates a new TabsHandler instance -func NewTabsHandler(store *store.Store, googleCalendarClient api.GoogleCalendarAPI, templateDir string) *TabsHandler { - // Parse templates including partials - tmpl, err := template.ParseGlob(filepath.Join(templateDir, "*.html")) - if err != nil { - log.Printf("Warning: failed to parse templates: %v", err) - } - - // Also parse partials - tmpl, err = tmpl.ParseGlob(filepath.Join(templateDir, "partials", "*.html")) - if err != nil { - log.Printf("Warning: failed to parse partial templates: %v", err) - } - - return &TabsHandler{ - store: store, - googleCalendarClient: googleCalendarClient, - templates: tmpl, - } -} - -// HandleTasks renders the unified Tasks tab (Todoist + Trello cards with due dates) -func (h *TabsHandler) HandleTasks(w http.ResponseWriter, r *http.Request) { - // Fetch Todoist tasks - tasks, err := h.store.GetTasks() - if err != nil { - http.Error(w, "Failed to fetch tasks", http.StatusInternalServerError) - log.Printf("Error fetching tasks: %v", err) - return - } - - // Fetch Trello boards - boards, err := h.store.GetBoards() - if err != nil { - http.Error(w, "Failed to fetch boards", http.StatusInternalServerError) - log.Printf("Error fetching boards: %v", err) - return - } - - // Convert to Atoms - atoms := make([]models.Atom, 0) - - // Convert Todoist tasks - for _, task := range tasks { - if !task.Completed { - atoms = append(atoms, models.TaskToAtom(task)) - } - } - - // Convert Trello cards with due dates or in actionable lists - for _, board := range boards { - for _, card := range board.Cards { - if card.DueDate != nil || isActionableList(card.ListName) { - atoms = append(atoms, models.CardToAtom(card)) - } - } - } - - // Compute UI fields (IsOverdue, IsFuture, HasSetTime) - for i := range atoms { - atoms[i].ComputeUIFields() - } - - // Sort atoms by urgency tiers: - // 1. Overdue (before today) - // 2. Today with specific time - // 3. Today all-day (midnight) - // 4. Future - // 5. No due date - // Within each tier: sort by due date/time, then by priority - sort.SliceStable(atoms, func(i, j int) bool { - // Compute urgency tier (lower = more urgent) - tierI := atomUrgencyTier(atoms[i]) - tierJ := atomUrgencyTier(atoms[j]) - - if tierI != tierJ { - return tierI < tierJ - } - - // Same tier: sort by due date/time if both have dates - if atoms[i].DueDate != nil && atoms[j].DueDate != nil { - if !atoms[i].DueDate.Equal(*atoms[j].DueDate) { - return atoms[i].DueDate.Before(*atoms[j].DueDate) - } - } - - // Same due date/time (or both nil), sort by priority (descending) - return atoms[i].Priority > atoms[j].Priority - }) - - // Partition atoms into current (overdue + today) and future - var currentAtoms, futureAtoms []models.Atom - for _, a := range atoms { - if a.IsFuture { - futureAtoms = append(futureAtoms, a) - } else { - currentAtoms = append(currentAtoms, a) - } - } - - // Render template - data := struct { - Atoms []models.Atom // Current tasks (overdue + today) - FutureAtoms []models.Atom // Future tasks (hidden by default) - Boards []models.Board - Today string - }{ - Atoms: currentAtoms, - FutureAtoms: futureAtoms, - Boards: boards, - Today: time.Now().Format("2006-01-02"), - } - - if err := h.templates.ExecuteTemplate(w, "tasks-tab", data); err != nil { - http.Error(w, "Failed to render template", http.StatusInternalServerError) - log.Printf("Error rendering tasks tab: %v", err) - } -} - -// ScheduledItem represents a scheduled event or task for the planning view -type ScheduledItem struct { - Type string // "event" or "task" - ID string - Title string - Description string - Start time.Time - End time.Time - URL string - Source string // "todoist", "trello", "calendar" - SourceIcon string - Priority int -} - -// HandlePlanning renders the Planning tab with structured sections -func (h *TabsHandler) HandlePlanning(w http.ResponseWriter, r *http.Request) { - now := time.Now() - today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) - tomorrow := today.AddDate(0, 0, 1) - in3Days := today.AddDate(0, 0, 4) // End of 3rd day - - // Fetch Trello boards - boards, err := h.store.GetBoards() - if err != nil { - log.Printf("Error fetching boards: %v", err) - boards = []models.Board{} - } - - // Fetch Todoist tasks - tasks, err := h.store.GetTasks() - if err != nil { - log.Printf("Error fetching tasks: %v", err) - tasks = []models.Task{} - } - - // Fetch Google Calendar events - var events []models.CalendarEvent - if h.googleCalendarClient != nil { - events, err = h.googleCalendarClient.GetUpcomingEvents(r.Context(), 20) - if err != nil { - log.Printf("Error fetching calendar events: %v", err) - } - } - - // Categorize into sections - var scheduled []ScheduledItem // Events and timed tasks for today - var unscheduled []models.Atom // Tasks due today without specific time - var upcoming []ScheduledItem // Events and tasks for next 3 days - - // Process calendar events - for _, event := range events { - item := ScheduledItem{ - Type: "event", - ID: event.ID, - Title: event.Summary, - Description: event.Description, - Start: event.Start, - End: event.End, - URL: event.HTMLLink, - Source: "calendar", - SourceIcon: "📅", - } - - if event.Start.Before(tomorrow) { - scheduled = append(scheduled, item) - } else if event.Start.Before(in3Days) { - upcoming = append(upcoming, item) - } - } - - // Process Todoist tasks - for _, task := range tasks { - if task.Completed || task.DueDate == nil { - continue - } - dueDate := *task.DueDate - - // Check if task has a specific time (not midnight) - hasTime := dueDate.Hour() != 0 || dueDate.Minute() != 0 - - if dueDate.Before(tomorrow) { - if hasTime { - // Timed task for today -> scheduled - scheduled = append(scheduled, ScheduledItem{ - Type: "task", - ID: task.ID, - Title: task.Content, - Start: dueDate, - URL: task.URL, - Source: "todoist", - SourceIcon: "✓", - Priority: task.Priority, - }) - } else { - // All-day task for today -> unscheduled - 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, - }) - } - } - - // Process Trello cards with due dates - for _, board := range boards { - for _, card := range board.Cards { - if card.DueDate == nil { - continue - } - dueDate := *card.DueDate - hasTime := dueDate.Hour() != 0 || dueDate.Minute() != 0 - - if dueDate.Before(tomorrow) { - if hasTime { - scheduled = append(scheduled, ScheduledItem{ - Type: "task", - ID: card.ID, - Title: card.Name, - Start: dueDate, - URL: card.URL, - Source: "trello", - SourceIcon: "📋", - }) - } else { - atom := models.CardToAtom(card) - atom.ComputeUIFields() - unscheduled = append(unscheduled, atom) - } - } else if dueDate.Before(in3Days) { - upcoming = append(upcoming, ScheduledItem{ - Type: "task", - ID: card.ID, - Title: card.Name, - Start: dueDate, - URL: card.URL, - Source: "trello", - SourceIcon: "📋", - }) - } - } - } - - // Sort scheduled by start time - sort.Slice(scheduled, func(i, j int) bool { - return scheduled[i].Start.Before(scheduled[j].Start) - }) - - // Sort unscheduled by priority (higher first) - sort.Slice(unscheduled, func(i, j int) bool { - return unscheduled[i].Priority > unscheduled[j].Priority - }) - - // Sort upcoming by date - sort.Slice(upcoming, func(i, j int) bool { - return upcoming[i].Start.Before(upcoming[j].Start) - }) - - data := struct { - Scheduled []ScheduledItem - Unscheduled []models.Atom - Upcoming []ScheduledItem - Boards []models.Board - Today string - }{ - Scheduled: scheduled, - Unscheduled: unscheduled, - Upcoming: upcoming, - Boards: boards, - Today: today.Format("2006-01-02"), - } - - if err := h.templates.ExecuteTemplate(w, "planning-tab", data); err != nil { - http.Error(w, "Failed to render template", http.StatusInternalServerError) - log.Printf("Error rendering planning tab: %v", err) - } -} - -// HandleMeals renders the Meals tab (PlanToEat) -func (h *TabsHandler) HandleMeals(w http.ResponseWriter, r *http.Request) { - // Fetch meals for next 7 days - startDate := time.Now() - endDate := startDate.AddDate(0, 0, 7) - - meals, err := h.store.GetMeals(startDate, endDate) - if err != nil { - http.Error(w, "Failed to fetch meals", http.StatusInternalServerError) - log.Printf("Error fetching meals: %v", err) - return - } - - data := struct { - Meals []models.Meal - }{ - Meals: meals, - } - - if err := h.templates.ExecuteTemplate(w, "meals-tab", data); err != nil { - http.Error(w, "Failed to render template", http.StatusInternalServerError) - log.Printf("Error rendering meals tab: %v", err) - } -} diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index a4d01a2..5b67234 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -595,3 +595,100 @@ func (s *Store) GetBugs() ([]Bug, error) { } return bugs, rows.Err() } + +// GetTasksByDateRange retrieves tasks due within a specific date range +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 BETWEEN ? AND ? + ORDER BY due_date ASC, priority DESC + `, start, end) + if err != nil { + return nil, err + } + defer rows.Close() + + var tasks []models.Task + for rows.Next() { + 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 nil, 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{} + } + tasks = append(tasks, task) + } + + return tasks, rows.Err() +} + +// GetMealsByDateRange retrieves meals within a specific date range +func (s *Store) GetMealsByDateRange(start, end time.Time) ([]models.Meal, error) { + return s.GetMeals(start, end) +} + +// GetCardsByDateRange retrieves cards due within a specific date range +func (s *Store) GetCardsByDateRange(start, end time.Time) ([]models.Card, error) { + rows, err := s.db.Query(` + SELECT c.id, c.name, b.name, c.list_id, c.list_name, c.due_date, c.url + FROM cards c + JOIN boards b ON c.board_id = b.id + WHERE c.due_date BETWEEN ? AND ? + ORDER BY c.due_date ASC + `, start, end) + if err != nil { + return nil, err + } + defer rows.Close() + + var cards []models.Card + for rows.Next() { + var card models.Card + var dueDate sql.NullTime + + err := rows.Scan( + &card.ID, + &card.Name, + &card.BoardName, + &card.ListID, + &card.ListName, + &dueDate, + &card.URL, + ) + if err != nil { + return nil, err + } + + if dueDate.Valid { + card.DueDate = &dueDate.Time + } + + cards = append(cards, card) + } + + return cards, rows.Err() +} diff --git a/internal/store/sqlite_test.go b/internal/store/sqlite_test.go index c09f3ca..7027cbe 100644 --- a/internal/store/sqlite_test.go +++ b/internal/store/sqlite_test.go @@ -89,6 +89,39 @@ func setupTestStoreWithCards(t *testing.T) *Store { return store } +// setupTestStoreWithMeals creates a test store with meals table +func setupTestStoreWithMeals(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 meals ( + id TEXT PRIMARY KEY, + recipe_name TEXT NOT NULL, + date DATETIME, + meal_type TEXT, + recipe_url TEXT, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + ` + if _, err := db.Exec(schema); err != nil { + t.Fatalf("Failed to create schema: %v", err) + } + + return store +} + // TestDeleteTask verifies that DeleteTask removes a task from the cache func TestDeleteTask(t *testing.T) { store := setupTestStoreWithTasks(t) @@ -430,3 +463,109 @@ func TestSaveAndGetBoards_ManyBoards(t *testing.T) { t.Errorf("Expected %d total cards, got %d", expectedTotal, totalCards) } } + +func TestGetTasksByDateRange(t *testing.T) { + store := setupTestStoreWithTasks(t) + defer 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 + 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, got %d", len(results)) + } +} + +func TestGetMealsByDateRange(t *testing.T) { + store := setupTestStoreWithMeals(t) + defer store.Close() + + now := time.Now() + tomorrow := now.Add(24 * time.Hour) + + meals := []models.Meal{ + {ID: "1", RecipeName: "Meal 1", Date: now, MealType: "lunch"}, + {ID: "2", RecipeName: "Meal 2", Date: tomorrow, MealType: "dinner"}, + } + + if err := store.SaveMeals(meals); err != nil { + t.Fatalf("Failed to save meals: %v", err) + } + + start := now.Add(-1 * time.Hour) + end := now.Add(1 * time.Hour) + + results, err := store.GetMealsByDateRange(start, end) + if err != nil { + t.Fatalf("GetMealsByDateRange failed: %v", err) + } + + if len(results) != 1 { + t.Errorf("Expected 1 meal, got %d", len(results)) + } + if results[0].ID != "1" { + t.Errorf("Expected meal 1, got %s", results[0].ID) + } +} + +func TestGetCardsByDateRange(t *testing.T) { + store := setupTestStoreWithCards(t) + defer store.Close() + + now := time.Now() + tomorrow := now.Add(24 * time.Hour) + + // Create board + _, err := store.db.Exec(`INSERT INTO boards (id, name) VALUES (?, ?)`, "board1", "Test Board") + if err != nil { + t.Fatalf("Failed to create board: %v", err) + } + + // Create cards + _, err = store.db.Exec(` + INSERT INTO cards (id, name, board_id, due_date) + VALUES + (?, ?, ?, ?), + (?, ?, ?, ?) + `, + "card1", "Card 1", "board1", now, + "card2", "Card 2", "board1", tomorrow) + if err != nil { + t.Fatalf("Failed to insert cards: %v", err) + } + + start := now.Add(-1 * time.Hour) + end := now.Add(1 * time.Hour) + + results, err := store.GetCardsByDateRange(start, end) + if err != nil { + t.Fatalf("GetCardsByDateRange failed: %v", err) + } + + if len(results) != 1 { + t.Errorf("Expected 1 card, got %d", len(results)) + } + if results[0].ID != "card1" { + t.Errorf("Expected card1, got %s", results[0].ID) + } +} |
