1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
|
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
}
|