diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/api/todoist.go | 29 | ||||
| -rw-r--r-- | internal/api/todoist_test.go | 12 | ||||
| -rw-r--r-- | internal/api/trello.go | 8 | ||||
| -rw-r--r-- | internal/handlers/handlers.go | 4 | ||||
| -rw-r--r-- | internal/handlers/timeline_logic_test.go | 160 | ||||
| -rw-r--r-- | internal/models/atom.go | 40 | ||||
| -rw-r--r-- | internal/models/types.go | 8 |
7 files changed, 231 insertions, 30 deletions
diff --git a/internal/api/todoist.go b/internal/api/todoist.go index 6c998cf..2c94e08 100644 --- a/internal/api/todoist.go +++ b/internal/api/todoist.go @@ -41,11 +41,8 @@ type todoistTaskResponse struct { ProjectID string `json:"project_id"` Priority int `json:"priority"` Labels []string `json:"labels"` - Due *struct { - Date string `json:"date"` - Datetime string `json:"datetime"` - } `json:"due"` - URL string `json:"url"` + Due *dueInfo `json:"due"` + URL string `json:"url"` CreatedAt string `json:"created_at"` } @@ -73,11 +70,8 @@ type SyncItemResponse struct { ProjectID string `json:"project_id"` Priority int `json:"priority"` Labels []string `json:"labels"` - Due *struct { - Date string `json:"date"` - Datetime string `json:"datetime"` - } `json:"due"` - IsCompleted bool `json:"is_completed"` + Due *dueInfo `json:"due"` + IsCompleted bool `json:"is_completed"` IsDeleted bool `json:"is_deleted"` AddedAt string `json:"added_at"` } @@ -125,6 +119,9 @@ func (c *TodoistClient) GetTasks(ctx context.Context) ([]models.Task, error) { } task.DueDate = parseDueDate(apiTask.Due) + if apiTask.Due != nil { + task.IsRecurring = apiTask.Due.IsRecurring + } tasks = append(tasks, task) } @@ -276,10 +273,14 @@ func (c *TodoistClient) ReopenTask(ctx context.Context, taskID string) error { } // parseDueDate parses due date from API response -func parseDueDate(due *struct { - Date string `json:"date"` - Datetime string `json:"datetime"` -}) *time.Time { +// dueInfo represents the due date structure from Todoist API +type dueInfo struct { + Date string `json:"date"` + Datetime string `json:"datetime"` + IsRecurring bool `json:"is_recurring"` +} + +func parseDueDate(due *dueInfo) *time.Time { if due == nil { return nil } diff --git a/internal/api/todoist_test.go b/internal/api/todoist_test.go index 88f94f8..2fa6e28 100644 --- a/internal/api/todoist_test.go +++ b/internal/api/todoist_test.go @@ -104,20 +104,16 @@ func TestTodoistClient_CreateTask_WithDueDate(t *testing.T) { } // Return mock response with due date - dueStruct := struct { - Date string `json:"date"` - Datetime string `json:"datetime"` - }{ - Date: "2026-01-15", - Datetime: "", - } response := todoistTaskResponse{ ID: "task-789", Content: "Task with Due Date", ProjectID: "project-456", URL: "https://todoist.com/task/789", CreatedAt: time.Now().Format(time.RFC3339), - Due: &dueStruct, + Due: &dueInfo{ + Date: "2026-01-15", + Datetime: "", + }, } w.Header().Set("Content-Type", "application/json") diff --git a/internal/api/trello.go b/internal/api/trello.go index 4cf7e9e..a19bbea 100644 --- a/internal/api/trello.go +++ b/internal/api/trello.go @@ -86,7 +86,7 @@ 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 := c.authParams() - params.Set("filter", "visible") + params.Set("filter", "open") params.Set("fields", "id,name,idList,due,url,idBoard") var apiCards []trelloCardResponse @@ -95,7 +95,11 @@ func (c *TrelloClient) GetCards(ctx context.Context, boardID string) ([]models.C return nil, fmt.Errorf("failed to fetch cards: %w", err) } - log.Printf("Trello GetCards: board %s returned %d cards from API", boardID, len(apiCards)) + if len(apiCards) == 0 { + log.Printf("Trello GetCards: board %s returned 0 cards (may have only archived cards)", boardID) + } else { + log.Printf("Trello GetCards: board %s returned %d cards", boardID, len(apiCards)) + } // Fetch lists to get list names lists, err := c.getLists(ctx, boardID) diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 5014f39..5c86ce2 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -996,6 +996,10 @@ func (h *Handler) HandleTabTasks(w http.ResponseWriter, r *http.Request) { var currentAtoms, futureAtoms []models.Atom for _, a := range atoms { + // Don't show recurring tasks until the day they're due + if a.IsRecurring && a.IsFuture { + continue + } if a.IsFuture { futureAtoms = append(futureAtoms, a) } else { diff --git a/internal/handlers/timeline_logic_test.go b/internal/handlers/timeline_logic_test.go new file mode 100644 index 0000000..a0576d6 --- /dev/null +++ b/internal/handlers/timeline_logic_test.go @@ -0,0 +1,160 @@ +package handlers + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "task-dashboard/internal/models" + "task-dashboard/internal/store" + + _ "github.com/mattn/go-sqlite3" +) + +// MockCalendarClient implements GoogleCalendarAPI interface for testing +type MockCalendarClient struct { + Events []models.CalendarEvent + Err error +} + +func (m *MockCalendarClient) GetUpcomingEvents(ctx context.Context, maxResults int) ([]models.CalendarEvent, error) { + return m.Events, m.Err +} + +func (m *MockCalendarClient) GetEventsByDateRange(ctx context.Context, start, end time.Time) ([]models.CalendarEvent, error) { + return m.Events, m.Err +} + +func setupTestStore(t *testing.T) *store.Store { + t.Helper() + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "test.db") + migrationDir := filepath.Join(tempDir, "migrations") + + if err := os.MkdirAll(migrationDir, 0755); err != nil { + t.Fatalf("Failed to create migration dir: %v", err) + } + + schema := ` + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + content TEXT NOT NULL, + description TEXT, + project_id TEXT, + project_name TEXT, + due_date DATETIME, + priority INTEGER DEFAULT 1, + completed BOOLEAN DEFAULT FALSE, + labels TEXT, + url TEXT, + created_at DATETIME, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS meals ( + id TEXT PRIMARY KEY, + recipe_name TEXT NOT NULL, + date DATETIME, + meal_type TEXT, + recipe_url TEXT, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS boards ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS cards ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + board_id TEXT NOT NULL, + list_id TEXT, + list_name TEXT, + due_date DATETIME, + url TEXT, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + ` + if err := os.WriteFile(filepath.Join(migrationDir, "001_init.sql"), []byte(schema), 0644); err != nil { + t.Fatalf("Failed to write migration file: %v", err) + } + + // Initialize store (this creates tables) + s, err := store.New(dbPath, migrationDir) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + return s +} + +func TestBuildTimeline(t *testing.T) { + s := setupTestStore(t) + + // Fix a base time: 2023-01-01 08:00:00 + baseTime := time.Date(2023, 1, 1, 8, 0, 0, 0, time.UTC) + + // Task: 10:00 + taskDate := baseTime.Add(2 * time.Hour) + s.SaveTasks([]models.Task{ + {ID: "t1", Content: "Task 1", DueDate: &taskDate}, + }) + + // Meal: Lunch (defaults to 12:00) + mealDate := baseTime // Date part matters + s.SaveMeals([]models.Meal{ + {ID: "m1", RecipeName: "Lunch", Date: mealDate, MealType: "lunch"}, + }) + + // Card: 14:00 + cardDate := baseTime.Add(6 * time.Hour) + s.SaveBoards([]models.Board{ + { + ID: "b1", + Name: "Board 1", + Cards: []models.Card{ + {ID: "c1", Name: "Card 1", DueDate: &cardDate, ListID: "l1"}, + }, + }, + }) + + // Calendar Event: 09:00 + eventDate := baseTime.Add(1 * time.Hour) + mockCal := &MockCalendarClient{ + Events: []models.CalendarEvent{ + {ID: "e1", Summary: "Event 1", Start: eventDate, End: eventDate.Add(1 * time.Hour)}, + }, + } + + // Test Range: Full Day + start := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC) + end := time.Date(2023, 1, 2, 0, 0, 0, 0, time.UTC) + + items, err := BuildTimeline(context.Background(), s, mockCal, start, end) + if err != nil { + t.Fatalf("BuildTimeline failed: %v", err) + } + + if len(items) != 4 { + t.Errorf("Expected 4 items, got %d", len(items)) + } + + // Expected Order: + // 1. Event (09:00) + // 2. Task (10:00) + // 3. Meal (12:00) + // 4. Card (14:00) + + if items[0].Type != models.TimelineItemTypeEvent { + t.Errorf("Expected item 0 to be Event, got %s", items[0].Type) + } + if items[1].Type != models.TimelineItemTypeTask { + t.Errorf("Expected item 1 to be Task, got %s", items[1].Type) + } + if items[2].Type != models.TimelineItemTypeMeal { + t.Errorf("Expected item 2 to be Meal, got %s", items[2].Type) + } + if items[3].Type != models.TimelineItemTypeCard { + t.Errorf("Expected item 3 to be Card, got %s", items[3].Type) + } +} diff --git a/internal/models/atom.go b/internal/models/atom.go index 10d14d1..3e08896 100644 --- a/internal/models/atom.go +++ b/internal/models/atom.go @@ -1,6 +1,9 @@ package models -import "time" +import ( + "fmt" + "time" +) type AtomSource string @@ -8,6 +11,7 @@ const ( SourceTrello AtomSource = "trello" SourceTodoist AtomSource = "todoist" SourceMeal AtomSource = "plantoeat" + SourceBug AtomSource = "bug" ) type AtomType string @@ -16,6 +20,7 @@ const ( TypeTask AtomType = "task" TypeNote AtomType = "note" TypeMeal AtomType = "meal" + TypeBug AtomType = "bug" ) // Atom represents a unified unit of work or information @@ -34,11 +39,12 @@ type Atom struct { Priority int // Normalized: 1 (Low) to 4 (Urgent) // UI Helpers (to be populated by mappers) - SourceIcon string // e.g., "trello-icon.svg" or emoji - ColorClass string // e.g., "border-blue-500" - IsOverdue bool // True if due date is before today - IsFuture bool // True if due date is after today - HasSetTime bool // True if due time is not midnight (has specific time) + SourceIcon string // e.g., "trello-icon.svg" or emoji + ColorClass string // e.g., "border-blue-500" + IsOverdue bool // True if due date is before today + IsFuture bool // True if due date is after today + HasSetTime bool // True if due time is not midnight (has specific time) + IsRecurring bool // True if this is a recurring task // Original Data (for write operations) Raw interface{} @@ -89,6 +95,7 @@ func TaskToAtom(t Task) Atom { Priority: priority, SourceIcon: "🔴", // Red circle for Todoist ColorClass: "border-red-500", + IsRecurring: t.IsRecurring, Raw: t, } } @@ -135,3 +142,24 @@ func MealToAtom(m Meal) Atom { Raw: m, } } + +// BugToAtom converts a Bug to an Atom +func BugToAtom(b Bug) Atom { + // Bugs get high priority (3) to encourage fixing + priority := 3 + + return Atom{ + ID: fmt.Sprintf("bug-%d", b.ID), + Title: b.Description, + Description: "Bug Report", + Source: SourceBug, + Type: TypeBug, + URL: "", + DueDate: nil, // Bugs don't have due dates + CreatedAt: b.CreatedAt, + Priority: priority, + SourceIcon: "🐛", + ColorClass: "border-red-700", + Raw: b, + } +} diff --git a/internal/models/types.go b/internal/models/types.go index f45e346..0284a3a 100644 --- a/internal/models/types.go +++ b/internal/models/types.go @@ -15,6 +15,7 @@ type Task struct { Labels []string `json:"labels"` URL string `json:"url"` CreatedAt time.Time `json:"created_at"` + IsRecurring bool `json:"is_recurring"` } // Meal represents a meal from PlanToEat @@ -100,6 +101,13 @@ type CalendarEvent struct { HTMLLink string `json:"html_link"` } +// Bug represents a bug report +type Bug struct { + ID int64 `json:"id"` + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` +} + // CacheMetadata tracks when data was last fetched type CacheMetadata struct { Key string `json:"key"` |
