summaryrefslogtreecommitdiff
path: root/internal/api
diff options
context:
space:
mode:
Diffstat (limited to 'internal/api')
-rw-r--r--internal/api/google_tasks.go15
-rw-r--r--internal/api/google_tasks_test.go40
2 files changed, 53 insertions, 2 deletions
diff --git a/internal/api/google_tasks.go b/internal/api/google_tasks.go
index b580be4..1f9aefb 100644
--- a/internal/api/google_tasks.go
+++ b/internal/api/google_tasks.go
@@ -95,14 +95,25 @@ func (c *GoogleTasksClient) getTasksFromList(ctx context.Context, listID string)
// 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
+ // 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 {
- dueInTZ := dueDate.In(c.displayTZ)
+ y, m, d := dueDate.Date()
+ dueInTZ := time.Date(y, m, d, 0, 0, 0, 0, c.displayTZ)
task.DueDate = &dueInTZ
}
}
diff --git a/internal/api/google_tasks_test.go b/internal/api/google_tasks_test.go
index fbfdd63..bdb0187 100644
--- a/internal/api/google_tasks_test.go
+++ b/internal/api/google_tasks_test.go
@@ -185,3 +185,43 @@ func TestGetTasksByDateRange_OutOfRangeExcluded(t *testing.T) {
t.Errorf("expected task-in-range, got %s", tasks[0].ID)
}
}
+
+// TestGetTasksFromList_DueDateNotShiftedByNegativeOffsetTZ guards against a
+// regression where a task due "tomorrow" showed up "today" on the widget.
+// Google Tasks' due field is date-only but always serialized as midnight
+// UTC. Converting that instant into a negative-offset display timezone
+// (e.g. Pacific/Honolulu, UTC-10) instead of re-anchoring the same calendar
+// date to local midnight shifted every dated task back by one day.
+func TestGetTasksFromList_DueDateNotShiftedByNegativeOffsetTZ(t *testing.T) {
+ honolulu, err := time.LoadLocation("Pacific/Honolulu")
+ if err != nil {
+ t.Skipf("Pacific/Honolulu tzdata not available: %v", err)
+ }
+
+ body := tasksAPIResponse([]map[string]interface{}{
+ {
+ "id": "task-tomorrow",
+ "title": "Due tomorrow",
+ "status": "needsAction",
+ "due": "2026-07-29T00:00:00.000Z",
+ "updated": "2026-07-28T00:00:00Z",
+ },
+ })
+ server := newTasksServer(body)
+ defer server.Close()
+
+ client := newTestGoogleTasksClient(t, server, "@default", honolulu)
+ tasks, err := client.GetTasks(context.Background())
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(tasks) != 1 {
+ t.Fatalf("expected 1 task, got %d", len(tasks))
+ }
+ if tasks[0].DueDate == nil {
+ t.Fatal("expected DueDate to be set")
+ }
+ if y, m, d := tasks[0].DueDate.Date(); y != 2026 || m != time.July || d != 29 {
+ t.Errorf("expected due date 2026-07-29, got %04d-%02d-%02d (task shifted a day earlier than Google reported)", y, m, d)
+ }
+}