package api import ( "context" "fmt" "log" "strings" "time" "task-dashboard/internal/models" "google.golang.org/api/option" "google.golang.org/api/tasks/v1" ) // GoogleTasksClient handles interactions with Google Tasks API type GoogleTasksClient struct { srv *tasks.Service tasklistID string displayTZ *time.Location } // NewGoogleTasksClient creates a client for Google Tasks. // tasklistID can be "@default" for the primary list, or a specific list ID. // Multiple lists can be comma-separated. func NewGoogleTasksClient(ctx context.Context, credentialsFile, tasklistID, timezone string) (*GoogleTasksClient, error) { srv, err := tasks.NewService(ctx, option.WithCredentialsFile(credentialsFile)) if err != nil { return nil, fmt.Errorf("unable to create Tasks client: %v", err) } if tasklistID == "" { tasklistID = "@default" } // Load display timezone displayTZ, err := time.LoadLocation(timezone) if err != nil { log.Printf("Warning: invalid timezone %q, using UTC: %v", timezone, err) displayTZ = time.UTC } return &GoogleTasksClient{ srv: srv, tasklistID: tasklistID, displayTZ: displayTZ, }, nil } // GetTasks fetches all incomplete tasks from the configured task list(s) func (c *GoogleTasksClient) GetTasks(ctx context.Context) ([]models.GoogleTask, error) { var allTasks []models.GoogleTask // Support comma-separated list IDs listIDs := strings.Split(c.tasklistID, ",") for _, listID := range listIDs { listID = strings.TrimSpace(listID) if listID == "" { continue } tasks, err := c.getTasksFromList(ctx, listID) if err != nil { log.Printf("Warning: failed to fetch tasks from list %s: %v", listID, err) continue } allTasks = append(allTasks, tasks...) } return allTasks, nil } // getTasksFromList fetches tasks from a specific list func (c *GoogleTasksClient) getTasksFromList(ctx context.Context, listID string) ([]models.GoogleTask, error) { call := c.srv.Tasks.List(listID). ShowCompleted(false). ShowHidden(false). MaxResults(100) taskList, err := call.Context(ctx).Do() if err != nil { return nil, fmt.Errorf("failed to list tasks: %v", err) } var result []models.GoogleTask for _, item := range taskList.Items { task := models.GoogleTask{ ID: item.Id, Title: item.Title, Notes: item.Notes, Status: item.Status, Completed: item.Status == "completed", ListID: listID, } // Parse due date if present (RFC3339 format, but date-only) if item.Due != "" { // Google Tasks due dates are in RFC3339 format but typically just the date part dueDate, err := time.Parse(time.RFC3339, item.Due) if err != nil { // Try date-only format dueDate, err = time.ParseInLocation("2006-01-02", item.Due[:10], c.displayTZ) } if err == nil { dueInTZ := dueDate.In(c.displayTZ) task.DueDate = &dueInTZ } } // Parse updated time if item.Updated != "" { if updated, err := time.Parse(time.RFC3339, item.Updated); err == nil { task.UpdatedAt = updated.In(c.displayTZ) } } // Build URL to Google Tasks task.URL = "https://tasks.google.com/embed/?origin=https://mail.google.com&fullWidth=1" result = append(result, task) } return result, nil } // GetTasksByDateRange fetches tasks with due dates in the specified range. // Tasks without due dates are included and treated as "today" tasks. func (c *GoogleTasksClient) GetTasksByDateRange(ctx context.Context, start, end time.Time) ([]models.GoogleTask, error) { allTasks, err := c.GetTasks(ctx) if err != nil { return nil, err } startDay := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, c.displayTZ) endDay := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, c.displayTZ) // Filter by date range, include tasks without due dates var filtered []models.GoogleTask for _, task := range allTasks { if task.DueDate == nil { // Include tasks without due dates (they'll be shown in "today" section) filtered = append(filtered, task) continue } dueDay := time.Date(task.DueDate.Year(), task.DueDate.Month(), task.DueDate.Day(), 0, 0, 0, 0, c.displayTZ) if !dueDay.Before(startDay) && dueDay.Before(endDay) { filtered = append(filtered, task) } } return filtered, nil } // CompleteTask marks a task as completed func (c *GoogleTasksClient) CompleteTask(ctx context.Context, listID, taskID string) error { task := &tasks.Task{ Status: "completed", } _, err := c.srv.Tasks.Patch(listID, taskID, task).Context(ctx).Do() if err != nil { return fmt.Errorf("failed to complete task: %v", err) } return nil } // UncompleteTask marks a task as not completed func (c *GoogleTasksClient) UncompleteTask(ctx context.Context, listID, taskID string) error { task := &tasks.Task{ Status: "needsAction", } _, err := c.srv.Tasks.Patch(listID, taskID, task).Context(ctx).Do() if err != nil { return fmt.Errorf("failed to uncomplete task: %v", err) } return nil } // SetTaskListID updates the task list ID(s) used by the client func (c *GoogleTasksClient) SetTaskListID(id string) { c.tasklistID = id } // GetTaskLists returns all task lists accessible to the user func (c *GoogleTasksClient) GetTaskLists(ctx context.Context) ([]models.TaskListInfo, error) { list, err := c.srv.Tasklists.List().Context(ctx).Do() if err != nil { return nil, fmt.Errorf("failed to fetch task lists: %v", err) } var lists []models.TaskListInfo for _, item := range list.Items { lists = append(lists, models.TaskListInfo{ ID: item.Id, Name: item.Title, }) } return lists, nil }