diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-01-26 20:55:50 -1000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-01-26 20:55:50 -1000 |
| commit | a3156a2f399ea03c645ee23b0099d9d722ce7e1e (patch) | |
| tree | 03c813717e77ae27d8aee9e676f1b75a6a01648c /internal/api/google_tasks.go | |
| parent | 70e6e51b6781a3986c51e3496b81c88665286872 (diff) | |
Add Google Tasks integration (#43)
- New GoogleTasksClient for fetching and managing Google Tasks
- Tasks appear in Timeline view with yellow indicator dot
- Tap checkbox to complete/uncomplete tasks via Google API
- Shares credentials file with Google Calendar (GOOGLE_CREDENTIALS_FILE)
- Configure task list via GOOGLE_TASKS_LIST_ID env var (default: @default)
- Supports comma-separated list IDs for multiple lists
New files:
- internal/api/google_tasks.go - Google Tasks API client
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'internal/api/google_tasks.go')
| -rw-r--r-- | internal/api/google_tasks.go | 173 |
1 files changed, 173 insertions, 0 deletions
diff --git a/internal/api/google_tasks.go b/internal/api/google_tasks.go new file mode 100644 index 0000000..0b4d7c2 --- /dev/null +++ b/internal/api/google_tasks.go @@ -0,0 +1,173 @@ +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 = fmt.Sprintf("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 +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 + } + + // Filter by date range + var filtered []models.GoogleTask + for _, task := range allTasks { + if task.DueDate == nil { + continue + } + dueDay := time.Date(task.DueDate.Year(), task.DueDate.Month(), task.DueDate.Day(), 0, 0, 0, 0, c.displayTZ) + 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) + + 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 +} |
