summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-01-23 21:37:18 -1000
committerPeter Stone <thepeterstone@gmail.com>2026-01-23 21:37:18 -1000
commit465093343ddd398ce5f6377fc9c472d8251c618b (patch)
treed333a2f1c8879f7b114817e929c95e9fcf5f4c3b
parente23c85577cbb0eac8b847dd989072698ff4e7a30 (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>
-rw-r--r--cmd/dashboard/main.go7
-rw-r--r--internal/api/http.go143
-rw-r--r--internal/api/plantoeat.go67
-rw-r--r--internal/api/todoist.go264
-rw-r--r--internal/api/todoist_test.go38
-rw-r--r--internal/api/trello.go211
-rw-r--r--internal/api/trello_test.go35
-rw-r--r--internal/handlers/cache.go52
-rw-r--r--internal/handlers/handlers.go1029
-rw-r--r--internal/handlers/heuristic_test.go152
-rw-r--r--internal/handlers/response.go36
-rw-r--r--internal/handlers/tabs.go384
-rw-r--r--internal/store/sqlite.go97
-rw-r--r--internal/store/sqlite_test.go139
14 files changed, 1215 insertions, 1439 deletions
diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go
index 050c8d0..8716db9 100644
--- a/cmd/dashboard/main.go
+++ b/cmd/dashboard/main.go
@@ -100,7 +100,6 @@ func main() {
// Initialize handlers
h := handlers.New(db, todoistClient, trelloClient, planToEatClient, googleCalendarClient, cfg)
- tabsHandler := handlers.NewTabsHandler(db, googleCalendarClient, cfg.TemplateDir)
// Set up router
r := chi.NewRouter()
@@ -133,9 +132,9 @@ func main() {
r.Get("/api/boards", h.HandleGetBoards)
// Tab routes for HTMX
- r.Get("/tabs/tasks", tabsHandler.HandleTasks)
- r.Get("/tabs/planning", tabsHandler.HandlePlanning)
- r.Get("/tabs/meals", tabsHandler.HandleMeals)
+ r.Get("/tabs/tasks", h.HandleTabTasks)
+ r.Get("/tabs/planning", h.HandleTabPlanning)
+ r.Get("/tabs/meals", h.HandleTabMeals)
r.Post("/tabs/refresh", h.HandleRefreshTab)
// Trello card operations
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)
+ }
+}