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 authenticated via // the given option (e.g. option.WithHTTPClient for OAuth -- see // NewGoogleTasksOAuthClient in google_tasks_oauth.go). Service-account auth // (option.WithCredentialsFile) does NOT work for Tasks: a service account // has no path to a regular user's personal task lists, unlike Calendar, // which supports sharing a calendar with any email including a service // account's. Confirmed 2026-07-14 (see project_doot_google_tasks_oauth_limitation // memory) -- always returns exactly one list ("My Tasks") with zero items, // the service account's own empty default list, never the real user's. // tasklistID can be "@default" for the primary list, or a specific list ID. // Multiple lists can be comma-separated. func NewGoogleTasksClient(ctx context.Context, clientOpt option.ClientOption, tasklistID, timezone string) (*GoogleTasksClient, error) { srv, err := tasks.NewService(ctx, clientOpt) 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 date-only, but the API always // serializes them as an RFC3339 timestamp fixed at midnight UTC // regardless of the user's timezone. Converting that instant // into displayTZ (dueDate.In(c.displayTZ)) reinterprets it as a // real moment in time and shifts the calendar date backward for // any timezone behind UTC -- midnight UTC becomes 2pm the // PREVIOUS day in UTC-10, silently moving every dated Google // Task one day earlier than its actual due date. The Y/M/D of // the UTC-anchored timestamp IS the intended calendar date, so // re-anchor those same components to displayTZ midnight instead // of converting the instant. 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 { y, m, d := dueDate.Date() dueInTZ := time.Date(y, m, d, 0, 0, 0, 0, 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 } // UpdateTask updates a task's title and notes (description). An empty // title is omitted from the PATCH body by the underlying client library // (omitempty), not sent as a blank -- so callers that don't support editing // title (e.g. the widget's description-only edit popup) can pass the // task's existing title back unchanged, or "" to leave it untouched. func (c *GoogleTasksClient) UpdateTask(ctx context.Context, listID, taskID, title, notes string) error { task := &tasks.Task{ Title: title, Notes: notes, } _, err := c.srv.Tasks.Patch(listID, taskID, task).Context(ctx).Do() if err != nil { return fmt.Errorf("failed to update 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 }