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 (m *MockCalendarClient) GetCalendarList(ctx context.Context) ([]models.CalendarInfo, error) { return nil, 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, nil, 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) } } func TestCalcCalendarBounds(t *testing.T) { tests := []struct { name string items []models.TimelineItem currentHour int wantStart int wantEnd int }{ { name: "no timed events returns default", items: []models.TimelineItem{}, currentHour: -1, wantStart: 8, wantEnd: 18, }, { name: "single event at 10am", items: []models.TimelineItem{ {Time: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC)}, }, currentHour: -1, wantStart: 9, // 1 hour buffer before wantEnd: 11, // 1 hour buffer after }, { name: "includes current hour", items: []models.TimelineItem{ {Time: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC)}, }, currentHour: 8, wantStart: 7, // 1 hour before 8am wantEnd: 11, // 1 hour after 10am }, { name: "event with end time extends range", items: []models.TimelineItem{ { Time: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC), EndTime: timePtr(time.Date(2023, 1, 1, 14, 0, 0, 0, time.UTC)), }, }, currentHour: -1, wantStart: 9, // 1 hour before 10am wantEnd: 15, // 1 hour after 2pm end }, { name: "all-day events are skipped", items: []models.TimelineItem{ {Time: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), IsAllDay: true}, }, currentHour: -1, wantStart: 8, wantEnd: 18, }, { name: "overdue events are skipped", items: []models.TimelineItem{ {Time: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC), IsOverdue: true}, }, currentHour: -1, wantStart: 8, wantEnd: 18, }, { name: "clamps to 0-23 range", items: []models.TimelineItem{ {Time: time.Date(2023, 1, 1, 0, 30, 0, 0, time.UTC)}, {Time: time.Date(2023, 1, 1, 23, 0, 0, 0, time.UTC)}, }, currentHour: -1, wantStart: 0, // Can't go below 0 wantEnd: 23, // Can't go above 23 }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { start, end := calcCalendarBounds(tc.items, tc.currentHour) if start != tc.wantStart { t.Errorf("Expected start %d, got %d", tc.wantStart, start) } if end != tc.wantEnd { t.Errorf("Expected end %d, got %d", tc.wantEnd, end) } }) } } func timePtr(t time.Time) *time.Time { return &t }